feat: v0.3.0 re-release - add dashboard & sessions, hide pi option
- Usage dashboard: KPIs, model split, daily bars, year heatmap, recent requests - Sessions management: list/preview/archive/delete with streaming preview - Double-click daily bar opens per-model breakdown modal - Paginated recent requests (30/page) - Hide pi option from nav (kept code, just removed from UI) - Timezone fixes: today/day_key/heatmap now use local calendar day - Streaming preview with 20MB byte cap + 500-char frontend clip - README attribution to kimicode-dashboard (MIT, JochenYang)
This commit is contained in:
1 parent
139af18826
commit
32de98bff5
19 files changed
+3648
-35
No files matched your search
@@ -326,6 +326,10 @@ A: 代码不依赖 Windows 专属 API,但 `tauri.conf.json` 的 bundle 目标
|
|||||||
- [Tauri](https://tauri.app) — 桌面应用框架
|
- [Tauri](https://tauri.app) — 桌面应用框架
|
||||||
- 设计文档见 `docs/superpowers/specs/`,实施计划见 `docs/superpowers/plans/`
|
- 设计文档见 `docs/superpowers/specs/`,实施计划见 `docs/superpowers/plans/`
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
用量仪表盘与会话管理功能基于 [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard)(MIT 许可证,© JochenYang)移植。原始项目实现了 Kimi Code CLI 的本地 Token 用量统计与会话管理,本项目的 Rust 后端(`src-tauri/src/dashboard.rs`)、前端仪表盘(`src/components/dashboard/`)以及会话管理页(`src/components/sessions/`)均源自该项目,感谢原作者的开源贡献。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
许可证:MIT,详见 [LICENSE](./LICENSE)。Copyright (c) 2026 CodingPlan.site
|
许可证:MIT,详见 [LICENSE](./LICENSE)。Copyright (c) 2026 CodingPlan.site
|
||||||
@@ -326,6 +326,10 @@ A: The code doesn't depend on Windows-only APIs, but `tauri.conf.json` only targ
|
|||||||
- [Tauri](https://tauri.app) — the desktop app framework
|
- [Tauri](https://tauri.app) — the desktop app framework
|
||||||
- Design specs in `docs/superpowers/specs/`, implementation plans in `docs/superpowers/plans/`
|
- Design specs in `docs/superpowers/specs/`, implementation plans in `docs/superpowers/plans/`
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
The usage dashboard and session manager are ported from [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard) (MIT License, © JochenYang). The original project implements local token usage analytics and session management for Kimi Code CLI. The Rust backend (`src-tauri/src/dashboard.rs`), dashboard UI (`src/components/dashboard/`), and sessions page (`src/components/sessions/`) in this project are derived from that work. Many thanks to the original author.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
License: MIT, see [LICENSE](./LICENSE). Copyright (c) 2026 CodingPlan.site
|
License: MIT, see [LICENSE](./LICENSE). Copyright (c) 2026 CodingPlan.site
|
||||||
Generated
+9
@@ -1091,6 +1091,12 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs_extra"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1957,7 +1963,9 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
"fs_extra",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1967,6 +1975,7 @@ dependencies = [
|
|||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"toml 0.8.23",
|
"toml 0.8.23",
|
||||||
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ tauri-plugin-single-instance = "2.0.0"
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
indexmap = { version = "2.2", features = ["serde"] }
|
indexmap = { version = "2.2", features = ["serde"] }
|
||||||
chrono = "0.4"
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
regex = "1"
|
||||||
|
walkdir = "2"
|
||||||
|
fs_extra = "1"
|
||||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod config_io;
|
pub mod config_io;
|
||||||
|
pub mod dashboard;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod kimi_code_io;
|
pub mod kimi_code_io;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
@@ -99,6 +100,15 @@ pub fn run() {
|
|||||||
commands::get_app_version,
|
commands::get_app_version,
|
||||||
commands::list_provider_models,
|
commands::list_provider_models,
|
||||||
commands::debug_log,
|
commands::debug_log,
|
||||||
|
dashboard::get_paths,
|
||||||
|
dashboard::get_prices,
|
||||||
|
dashboard::get_summary,
|
||||||
|
dashboard::list_sessions,
|
||||||
|
dashboard::archive_session,
|
||||||
|
dashboard::unarchive_session,
|
||||||
|
dashboard::delete_session,
|
||||||
|
dashboard::delete_workspace,
|
||||||
|
dashboard::get_session_preview,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
+46
-34
@@ -4,6 +4,8 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
|
|||||||
import { useConfig } from "./hooks/useConfig";
|
import { useConfig } from "./hooks/useConfig";
|
||||||
import { ProviderList } from "./components/ProviderList";
|
import { ProviderList } from "./components/ProviderList";
|
||||||
import { ProviderEdit } from "./components/ProviderEdit";
|
import { ProviderEdit } from "./components/ProviderEdit";
|
||||||
|
import { DashboardPage } from "./components/dashboard/DashboardPage";
|
||||||
|
import { SessionsPage } from "./components/sessions/SessionsPage";
|
||||||
import { useTranslation } from "./i18n";
|
import { useTranslation } from "./i18n";
|
||||||
import { getDefaultMaxContextSize } from "./lib/model-defaults";
|
import { getDefaultMaxContextSize } from "./lib/model-defaults";
|
||||||
import { getModelRef } from "./lib/models-dev";
|
import { getModelRef } from "./lib/models-dev";
|
||||||
@@ -35,24 +37,19 @@ function findFirstModelForProvider(models: Record<string, Model>, providerName:
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AGENTS: { key: Agent; label: string }[] = [
|
|
||||||
{ key: "kimi_code", label: "Kimi Code" },
|
|
||||||
{ key: "pi", label: "Pi" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function getInitialAgent(): Agent {
|
function getInitialAgent(): Agent {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(AGENT_STORAGE_KEY) as Agent | null;
|
const stored = localStorage.getItem(AGENT_STORAGE_KEY) as Agent | null;
|
||||||
if (stored === "kimi_code" || stored === "pi") return stored;
|
if (stored === "kimi_code") return stored;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
return "pi";
|
return "kimi_code";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t, lang, setLang } = useTranslation();
|
const { t, lang, setLang } = useTranslation();
|
||||||
const [agent, setAgentState] = useState<Agent>(getInitialAgent);
|
const [agent] = useState<Agent>(getInitialAgent);
|
||||||
const {
|
const {
|
||||||
config,
|
config,
|
||||||
dirty,
|
dirty,
|
||||||
@@ -63,21 +60,11 @@ export default function App() {
|
|||||||
updateConfig,
|
updateConfig,
|
||||||
} = useConfig(agent);
|
} = useConfig(agent);
|
||||||
|
|
||||||
const [view, setView] = useState<"list" | "edit">("list");
|
const [view, setView] = useState<"list" | "edit" | "dashboard" | "sessions">("list");
|
||||||
const [editingProvider, setEditingProvider] = useState<string>("");
|
const [editingProvider, setEditingProvider] = useState<string>("");
|
||||||
const [loadTimeout, setLoadTimeout] = useState(false);
|
const [loadTimeout, setLoadTimeout] = useState(false);
|
||||||
const [switchMessage, setSwitchMessage] = useState<string | null>(null);
|
const [switchMessage, setSwitchMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
const setAgent = (next: Agent) => {
|
|
||||||
setAgentState(next);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(AGENT_STORAGE_KEY, next);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
setView("list");
|
|
||||||
setEditingProvider("");
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading) {
|
if (!loading) {
|
||||||
@@ -377,20 +364,39 @@ export default function App() {
|
|||||||
<div className="flex flex-col h-full bg-[#0f0f11] text-[#e5e5e7]">
|
<div className="flex flex-col h-full bg-[#0f0f11] text-[#e5e5e7]">
|
||||||
<header className="flex items-center justify-between gap-3 px-4 py-3 border-b border-[#2a2a2e] bg-[#16161a]">
|
<header className="flex items-center justify-between gap-3 px-4 py-3 border-b border-[#2a2a2e] bg-[#16161a]">
|
||||||
<div className="flex items-center bg-[#1f1f23] border border-[#2a2a2e] rounded p-0.5">
|
<div className="flex items-center bg-[#1f1f23] border border-[#2a2a2e] rounded p-0.5">
|
||||||
{AGENTS.map(({ key, label }) => (
|
<button
|
||||||
<button
|
type="button"
|
||||||
key={key}
|
onClick={() => setView("list")}
|
||||||
type="button"
|
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||||
onClick={() => setAgent(key)}
|
view === "list" || view === "edit"
|
||||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
? "bg-blue-600 text-white"
|
||||||
agent === key
|
: "text-gray-400 hover:text-[#e5e5e7]"
|
||||||
? "bg-blue-600 text-white"
|
}`}
|
||||||
: "text-gray-400 hover:text-[#e5e5e7]"
|
>
|
||||||
}`}
|
{t("providers")}
|
||||||
>
|
</button>
|
||||||
{label}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
))}
|
onClick={() => setView("dashboard")}
|
||||||
|
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||||
|
view === "dashboard"
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "text-gray-400 hover:text-[#e5e5e7]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("dashboard")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView("sessions")}
|
||||||
|
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||||
|
view === "sessions"
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "text-gray-400 hover:text-[#e5e5e7]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("sessions")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
@@ -418,7 +424,11 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<main className="flex-1 overflow-hidden relative">
|
<main className="flex-1 overflow-hidden relative">
|
||||||
{view === "list" ? (
|
{view === "dashboard" ? (
|
||||||
|
<DashboardPage />
|
||||||
|
) : view === "sessions" ? (
|
||||||
|
<SessionsPage />
|
||||||
|
) : view === "list" ? (
|
||||||
<ProviderList
|
<ProviderList
|
||||||
providers={providers}
|
providers={providers}
|
||||||
defaultModel={config.default_model}
|
defaultModel={config.default_model}
|
||||||
@@ -528,6 +538,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{view !== "dashboard" && view !== "sessions" && (
|
||||||
<footer className="flex items-center justify-between px-4 py-2 border-t border-[#2a2a2e] bg-[#16161a] text-sm">
|
<footer className="flex items-center justify-between px-4 py-2 border-t border-[#2a2a2e] bg-[#16161a] text-sm">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{dirty && (
|
{dirty && (
|
||||||
@@ -575,6 +586,7 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "../../i18n";
|
||||||
|
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||||
|
import type { DailyRow } from "../../types/dashboard";
|
||||||
|
import { DailyDetailModal } from "./DailyDetailModal";
|
||||||
|
|
||||||
|
interface DailyBarsProps {
|
||||||
|
daily: DailyRow[];
|
||||||
|
/** Ordered model names for the colour palette & legend */
|
||||||
|
modelNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic colour palette — one hue per model index */
|
||||||
|
const PALETTE = [
|
||||||
|
"#3b82f6", // blue
|
||||||
|
"#22c55e", // green
|
||||||
|
"#f97316", // orange
|
||||||
|
"#a855f7", // purple
|
||||||
|
"#ec4899", // pink
|
||||||
|
"#eab308", // yellow
|
||||||
|
"#06b6d4", // cyan
|
||||||
|
"#f43f5e", // rose
|
||||||
|
"#84cc16", // lime
|
||||||
|
"#8b5cf6", // violet
|
||||||
|
];
|
||||||
|
|
||||||
|
export function modelColor(index: number): string {
|
||||||
|
return PALETTE[index % PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short display name for a raw model id */
|
||||||
|
function shortModel(model: string): string {
|
||||||
|
const bare = model.includes("/") ? model.split("/").pop()! : model;
|
||||||
|
return bare;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DailyBars({ daily, modelNames }: DailyBarsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [selected, setSelected] = useState<DailyRow | null>(null);
|
||||||
|
|
||||||
|
const colorMap = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
modelNames.forEach((m, i) => {
|
||||||
|
map[m] = modelColor(i);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [modelNames]);
|
||||||
|
|
||||||
|
if (!daily?.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-gray-500">
|
||||||
|
{t("noData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Math.max(...daily.map((d) => d.totalTokens || 0), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{modelNames.map((m) => (
|
||||||
|
<span key={m} className="flex items-center gap-1.5 text-[11px] text-gray-400">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 rounded-sm"
|
||||||
|
style={{ backgroundColor: colorMap[m] }}
|
||||||
|
/>
|
||||||
|
{shortModel(m)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bars */}
|
||||||
|
<div className="flex h-52 items-end gap-1 overflow-x-auto px-0.5 pb-1">
|
||||||
|
{daily.map((d) => {
|
||||||
|
const h = Math.max(4, Math.round(((d.totalTokens || 0) / max) * 170));
|
||||||
|
const total = d.totalTokens || 1;
|
||||||
|
|
||||||
|
// Build stacked segments sorted by token count (largest at bottom)
|
||||||
|
const segments = Object.entries(d.byModel || {})
|
||||||
|
.map(([model, tokens]) => ({
|
||||||
|
model,
|
||||||
|
tokens,
|
||||||
|
color: colorMap[model] || "#6b7280",
|
||||||
|
height: Math.max(1, Math.round((tokens / total) * h)),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.tokens - a.tokens);
|
||||||
|
|
||||||
|
const tooltipLines = [
|
||||||
|
d.date,
|
||||||
|
`总 Token: ${fmtTokens(d.totalTokens)} · ${fmtUsd(d.costUsd)} · hit ${fmtPct(d.cacheHitRate)}`,
|
||||||
|
...segments.map(
|
||||||
|
(s) => `${shortModel(s.model)}: ${fmtTokens(s.tokens)}`
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
t("doubleClickHint"),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.date}
|
||||||
|
type="button"
|
||||||
|
title={tooltipLines}
|
||||||
|
onDoubleClick={() => setSelected(d)}
|
||||||
|
className="group flex min-w-[18px] flex-1 flex-col items-center gap-1.5 border-0 bg-transparent p-0 cursor-help"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full flex flex-col-reverse rounded-t-md rounded-b-sm overflow-hidden transition-transform group-hover:scale-105 group-active:scale-95"
|
||||||
|
style={{ height: h }}
|
||||||
|
>
|
||||||
|
{segments.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.model}
|
||||||
|
className="w-full transition-opacity group-hover:opacity-80"
|
||||||
|
style={{
|
||||||
|
height: s.height,
|
||||||
|
backgroundColor: s.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="whitespace-nowrap text-[10px] text-gray-500">
|
||||||
|
{d.date.slice(5)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected &&
|
||||||
|
createPortal(
|
||||||
|
<DailyDetailModal
|
||||||
|
day={selected}
|
||||||
|
colorMap={colorMap}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
/>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "../../i18n";
|
||||||
|
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||||
|
import type { DailyRow } from "../../types/dashboard";
|
||||||
|
import { modelColor } from "./DailyBars";
|
||||||
|
|
||||||
|
interface DailyDetailModalProps {
|
||||||
|
day: DailyRow;
|
||||||
|
/** model name → colour hex */
|
||||||
|
colorMap: Record<string, string>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortModel(model: string): string {
|
||||||
|
const bare = model.includes("/") ? model.split("/").pop()! : model;
|
||||||
|
return bare;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// ESC to close
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Lock background scroll while modal is open
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const total = day.totalTokens || 1;
|
||||||
|
const segments = Object.entries(day.byModel || {})
|
||||||
|
.map(([model, tokens]) => ({
|
||||||
|
model,
|
||||||
|
tokens,
|
||||||
|
pct: (tokens / total) * 100,
|
||||||
|
color: colorMap[model] || "#6b7280",
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.tokens - a.tokens);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={day.date}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg rounded-xl border border-[#2a2a2e] bg-[#16161a] shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between border-b border-[#2a2a2e] px-5 py-3.5">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-gray-500">
|
||||||
|
{t("dayDetail")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-lg font-semibold text-[#e5e5e7]">
|
||||||
|
{day.date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t("close")}
|
||||||
|
className="rounded-md p-1 text-gray-500 hover:bg-[#2a2a2e] hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day summary chips */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 px-5 py-3 sm:grid-cols-4">
|
||||||
|
<SummaryChip
|
||||||
|
label={t("totalTokens")}
|
||||||
|
value={fmtTokens(day.totalTokens)}
|
||||||
|
tone="default"
|
||||||
|
/>
|
||||||
|
<SummaryChip
|
||||||
|
label={t("cost")}
|
||||||
|
value={fmtUsd(day.costUsd)}
|
||||||
|
tone="orange"
|
||||||
|
/>
|
||||||
|
<SummaryChip
|
||||||
|
label={t("requests")}
|
||||||
|
value={Math.round(day.requests).toLocaleString()}
|
||||||
|
tone="blue"
|
||||||
|
/>
|
||||||
|
<SummaryChip
|
||||||
|
label={t("cacheHitRate")}
|
||||||
|
value={fmtPct(day.cacheHitRate)}
|
||||||
|
tone="green"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-model breakdown */}
|
||||||
|
<div className="px-5 pb-4">
|
||||||
|
<div className="mb-2 text-[11px] uppercase tracking-wider text-gray-500">
|
||||||
|
{t("modelDistribution")} · {segments.length}
|
||||||
|
</div>
|
||||||
|
{segments.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-sm text-gray-500">
|
||||||
|
{t("noData")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 max-h-72 overflow-y-auto pr-1">
|
||||||
|
{segments.map((s, i) => (
|
||||||
|
<div key={s.model} className="flex items-center gap-3 text-sm">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 rounded-sm shrink-0"
|
||||||
|
style={{ backgroundColor: modelColor(i) }}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span
|
||||||
|
className="truncate text-[#e5e5e7]"
|
||||||
|
title={s.model}
|
||||||
|
>
|
||||||
|
{shortModel(s.model)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-gray-400 tabular-nums">
|
||||||
|
{fmtTokens(s.tokens)}{" "}
|
||||||
|
<span className="text-gray-500">
|
||||||
|
· {s.pct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-[#2a2a2e]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(2, s.pct)}%`,
|
||||||
|
backgroundColor: modelColor(i),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer hint */}
|
||||||
|
<div className="border-t border-[#2a2a2e] px-5 py-2 text-[11px] text-gray-500">
|
||||||
|
{t("modalEscHint")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryChip({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone: "default" | "orange" | "blue" | "green";
|
||||||
|
}) {
|
||||||
|
const valueClass =
|
||||||
|
tone === "orange"
|
||||||
|
? "text-orange-400"
|
||||||
|
: tone === "blue"
|
||||||
|
? "text-blue-400"
|
||||||
|
: tone === "green"
|
||||||
|
? "text-emerald-400"
|
||||||
|
: "text-[#e5e5e7]";
|
||||||
|
return (
|
||||||
|
<div className="rounded-md bg-[#1c1c20] px-3 py-2">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-gray-500">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className={`mt-0.5 text-sm font-semibold tabular-nums ${valueClass}`}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
|
||||||
|
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||||
|
import { DailyBars } from "./DailyBars";
|
||||||
|
import { Heatmap } from "./Heatmap";
|
||||||
|
|
||||||
|
const RANGES: { value: DashboardRange; label: string }[] = [
|
||||||
|
{ value: "today", label: "今天" },
|
||||||
|
{ value: "7d", label: "7 天" },
|
||||||
|
{ value: "30d", label: "30 天" },
|
||||||
|
{ value: "all", label: "全部" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[#2a2a2e] bg-[#16161a]">
|
||||||
|
<div className="border-b border-[#2a2a2e] px-4 py-2.5 flex items-baseline justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-[#e5e5e7]">{title}</h3>
|
||||||
|
{subtitle && <span className="text-xs text-gray-500">{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[#2a2a2e] bg-[#16161a] p-3">
|
||||||
|
<div className="text-[11px] text-gray-500">{label}</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-[#e5e5e7] tracking-tight">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{sub && <div className="mt-0.5 text-[11px] text-gray-500">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { range, changeRange, data, loading, error, refresh } = useDashboard();
|
||||||
|
const [showAllModels, setShowAllModels] = useState(false);
|
||||||
|
const [recentPage, setRecentPage] = useState(1);
|
||||||
|
|
||||||
|
if (loading && !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-gray-500">
|
||||||
|
<div className="animate-spin h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-red-400 mb-2">加载失败</div>
|
||||||
|
<div className="text-sm text-gray-500">{error}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refresh()}
|
||||||
|
className="mt-4 px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const totals = data.stats.totals;
|
||||||
|
const daily = data.stats.daily;
|
||||||
|
const models = data.stats.models;
|
||||||
|
const recent = data.stats.recent;
|
||||||
|
const heatmap = data.heatmap;
|
||||||
|
const rangeTotals = data.rangeTotals;
|
||||||
|
const visibleModels = showAllModels ? models : models.slice(0, 8);
|
||||||
|
|
||||||
|
// Recent pagination
|
||||||
|
const RECENT_PAGE_SIZE = 30;
|
||||||
|
const recentTotal = data.stats.recentTotal;
|
||||||
|
const recentTotalPages = Math.max(1, Math.ceil(recent.length / RECENT_PAGE_SIZE));
|
||||||
|
const recentCurrentPage = Math.min(recentPage, recentTotalPages);
|
||||||
|
const recentStart = (recentCurrentPage - 1) * RECENT_PAGE_SIZE;
|
||||||
|
const recentPageRows = recent.slice(recentStart, recentStart + RECENT_PAGE_SIZE);
|
||||||
|
|
||||||
|
// Ordered model names for chart colour mapping
|
||||||
|
const modelNames = models.map((m) => m.model);
|
||||||
|
|
||||||
|
const kpiItems = [
|
||||||
|
{ label: "请求数", value: fmtInt(totals.requests) },
|
||||||
|
{ label: "非缓存输入", value: fmtTokens(totals.inputOther), sub: fmtInt(totals.inputOther) },
|
||||||
|
{ label: "输出", value: fmtTokens(totals.output), sub: fmtInt(totals.output) },
|
||||||
|
{ label: "缓存读", value: fmtTokens(totals.inputCacheRead), sub: fmtInt(totals.inputCacheRead) },
|
||||||
|
{ label: "缓存创建", value: fmtTokens(totals.inputCacheCreation), sub: fmtInt(totals.inputCacheCreation) },
|
||||||
|
{ label: "缓存命中", value: fmtPct(totals.cacheHitRate) },
|
||||||
|
{ label: "总 Token", value: fmtTokens(totals.totalTokens), sub: fmtInt(totals.totalTokens) },
|
||||||
|
{ label: "预估费用", value: fmtUsd(totals.costUsd) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto p-4 space-y-4">
|
||||||
|
{/* Range tabs + refresh */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center bg-[#1f1f23] border border-[#2a2a2e] rounded p-0.5">
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeRange(r.value)}
|
||||||
|
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||||
|
range === r.value
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "text-gray-400 hover:text-[#e5e5e7]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => refresh()}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529] text-gray-400 hover:text-[#e5e5e7] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "刷新中…" : "↻ 刷新"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Range overview cards */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
{RANGES.map((r) => {
|
||||||
|
const tot = rangeTotals[r.value];
|
||||||
|
if (!tot) return null;
|
||||||
|
const active = range === r.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeRange(r.value)}
|
||||||
|
className={`rounded-xl border p-4 text-left transition-colors ${
|
||||||
|
active
|
||||||
|
? "border-blue-500/50 ring-1 ring-blue-500/30 bg-blue-900/10"
|
||||||
|
: "border-[#2a2a2e] bg-[#16161a] hover:border-[#3a3a42] hover:bg-[#1c1c20]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs text-gray-500">{r.label}</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-[#e5e5e7] tracking-tight">
|
||||||
|
{fmtTokens(tot.totalTokens)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-gray-500">
|
||||||
|
{fmtInt(tot.requests)} 次 · {fmtUsd(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI strip */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:grid-cols-8">
|
||||||
|
{kpiItems.map((item) => (
|
||||||
|
<KpiCard key={item.label} label={item.label} value={item.value} sub={item.sub} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heatmap — full-year strip, fixed-size cells, centered */}
|
||||||
|
<Card title="全年热力图" subtitle="按 Token 量着色">
|
||||||
|
<Heatmap heatmap={heatmap} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Charts + Model side table */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||||
|
<Card
|
||||||
|
title="每日用量趋势"
|
||||||
|
subtitle={
|
||||||
|
daily.length > 30
|
||||||
|
? `近 30 天 · 共 ${daily.length} 天`
|
||||||
|
: `按模型着色 · ${daily.length} 天`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DailyBars
|
||||||
|
daily={daily.length > 30 ? daily.slice(-30) : daily}
|
||||||
|
modelNames={modelNames}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="模型用量" subtitle={`${models.length} 个模型`}>
|
||||||
|
{models.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-gray-500">暂无数据</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[360px] overflow-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 bg-[#16161a]">
|
||||||
|
<tr className="text-left text-xs text-gray-500 border-b border-[#2a2a2e]">
|
||||||
|
<th className="pb-2 pr-3 font-normal">模型</th>
|
||||||
|
<th className="pb-2 pr-3 font-normal text-right">请求</th>
|
||||||
|
<th className="pb-2 pr-3 font-normal text-right">Token</th>
|
||||||
|
<th className="pb-2 pr-3 font-normal text-right">缓存命中</th>
|
||||||
|
<th className="pb-2 font-normal text-right">费用</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleModels.map((m) => (
|
||||||
|
<tr
|
||||||
|
key={m.model}
|
||||||
|
className="border-b border-[#2a2a2e]/50 hover:bg-[#1c1c20]"
|
||||||
|
>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 rounded-sm shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
models.indexOf(m) < 10
|
||||||
|
? ["#3b82f6","#22c55e","#f97316","#a855f7","#ec4899","#eab308","#06b6d4","#f43f5e","#84cc16","#8b5cf6"][models.indexOf(m)]
|
||||||
|
: "#6b7280",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[#e5e5e7] truncate">{m.modelDisplay || m.model}</div>
|
||||||
|
{m.costEstimated && (
|
||||||
|
<span className="text-[10px] text-yellow-600">估算</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-right text-gray-400">{fmtInt(m.requests)}</td>
|
||||||
|
<td className="py-2 pr-3 text-right text-gray-400">{fmtTokens(m.totalTokens)}</td>
|
||||||
|
<td className="py-2 pr-3 text-right text-gray-400">{fmtPct(m.cacheHitRate)}</td>
|
||||||
|
<td className="py-2 text-right text-orange-400">{fmtUsd(m.costUsd)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{models.length > 8 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAllModels(!showAllModels)}
|
||||||
|
className="mt-3 text-sm text-blue-500 hover:text-blue-400"
|
||||||
|
>
|
||||||
|
{showAllModels ? "收起" : `展开全部 (${models.length})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent requests */}
|
||||||
|
<Card
|
||||||
|
title="最近请求"
|
||||||
|
subtitle={
|
||||||
|
recentTotal > recent.length
|
||||||
|
? `显示前 ${recent.length} / ${fmtInt(recentTotal)} 条`
|
||||||
|
: `${recent.length} 条`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{recent.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-gray-500">暂无数据</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 bg-[#16161a]">
|
||||||
|
<tr className="text-left text-xs text-gray-500 border-b border-[#2a2a2e]">
|
||||||
|
<th className="pb-2 pr-4 font-normal">时间</th>
|
||||||
|
<th className="pb-2 pr-4 font-normal">模型</th>
|
||||||
|
<th className="pb-2 pr-4 font-normal text-right">输入</th>
|
||||||
|
<th className="pb-2 pr-4 font-normal text-right">输出</th>
|
||||||
|
<th className="pb-2 pr-4 font-normal text-right">缓存读</th>
|
||||||
|
<th className="pb-2 font-normal text-right">费用</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentPageRows.map((r, i) => (
|
||||||
|
<tr key={recentStart + i} className="border-b border-[#2a2a2e]/50 hover:bg-[#1c1c20]">
|
||||||
|
<td className="py-1.5 pr-4 text-gray-500 whitespace-nowrap">
|
||||||
|
{fmtTime(r.time)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-4 text-[#e5e5e7]">{r.modelDisplay || r.model}</td>
|
||||||
|
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.inputOther)}</td>
|
||||||
|
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.output)}</td>
|
||||||
|
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.inputCacheRead)}</td>
|
||||||
|
<td className="py-1.5 text-right text-orange-400">{fmtUsd(r.costUsd)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination footer */}
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-2 border-t border-[#2a2a2e] pt-2 text-xs text-gray-500">
|
||||||
|
<span>
|
||||||
|
第 {fmtInt(recentCurrentPage)} / {fmtInt(recentTotalPages)} 页 ·{" "}
|
||||||
|
{fmtInt(recentStart + 1)}–{fmtInt(Math.min(recentStart + RECENT_PAGE_SIZE, recent.length))} 条 ·{" "}
|
||||||
|
每页 {RECENT_PAGE_SIZE} 条
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={recentCurrentPage <= 1}
|
||||||
|
onClick={() => setRecentPage(1)}
|
||||||
|
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
aria-label="首页"
|
||||||
|
>
|
||||||
|
«
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={recentCurrentPage <= 1}
|
||||||
|
onClick={() => setRecentPage((p) => Math.max(1, p - 1))}
|
||||||
|
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
aria-label="上一页"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={recentCurrentPage >= recentTotalPages}
|
||||||
|
onClick={() => setRecentPage((p) => Math.min(recentTotalPages, p + 1))}
|
||||||
|
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
aria-label="下一页"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={recentCurrentPage >= recentTotalPages}
|
||||||
|
onClick={() => setRecentPage(recentTotalPages)}
|
||||||
|
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
aria-label="末页"
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Attribution footer */}
|
||||||
|
<div className="text-xs text-gray-500 pb-2">
|
||||||
|
扫描 {data.meta.filesScanned} 个文件 · {fmtInt(data.meta.recordCount)} 条记录 ·{" "}
|
||||||
|
{data.meta.home}
|
||||||
|
<br />
|
||||||
|
仪表盘功能基于{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/JochenYang/kimicode-dashboard"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-500 hover:text-blue-400 underline"
|
||||||
|
>
|
||||||
|
kimicode-dashboard
|
||||||
|
</a>{" "}
|
||||||
|
(MIT,© JochenYang)移植
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "../../i18n";
|
||||||
|
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||||
|
import type { HeatmapCell, HeatmapData } from "../../types/dashboard";
|
||||||
|
|
||||||
|
interface HeatmapProps {
|
||||||
|
heatmap: HeatmapData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_BG = [
|
||||||
|
"bg-[#1a1a1e]", // 0 — empty / no usage
|
||||||
|
"bg-emerald-900/55",
|
||||||
|
"bg-emerald-700/65",
|
||||||
|
"bg-emerald-500/85",
|
||||||
|
"bg-emerald-400",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DOW_LABELS_ZH = ["日", "一", "二", "三", "四", "五", "六"];
|
||||||
|
const DOW_LABELS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
|
||||||
|
const CELL = 12; // px
|
||||||
|
const GAP = 3; // px
|
||||||
|
const LABEL_W = 28; // dow label column width
|
||||||
|
|
||||||
|
interface HoverState {
|
||||||
|
cell: HeatmapCell;
|
||||||
|
x: number; // viewport coords
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Heatmap({ heatmap }: HeatmapProps) {
|
||||||
|
const { t, lang } = useTranslation();
|
||||||
|
const cells = heatmap?.cells || [];
|
||||||
|
const monthLabels = heatmap?.monthLabels || [];
|
||||||
|
|
||||||
|
const dowLabels = lang === "zh" ? DOW_LABELS_ZH : DOW_LABELS_EN;
|
||||||
|
|
||||||
|
const weekCount = useMemo(() => {
|
||||||
|
if (!cells.length) return 0;
|
||||||
|
return Math.max(...cells.map((c) => c.weekIndex)) + 1;
|
||||||
|
}, [cells]);
|
||||||
|
|
||||||
|
const grid = useMemo(() => {
|
||||||
|
const cols: (HeatmapCell | null)[][] = Array.from(
|
||||||
|
{ length: weekCount },
|
||||||
|
() => Array(7).fill(null)
|
||||||
|
);
|
||||||
|
for (const c of cells) {
|
||||||
|
if (c.weekIndex >= 0 && c.weekIndex < weekCount) {
|
||||||
|
cols[c.weekIndex][c.dow] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}, [cells, weekCount]);
|
||||||
|
|
||||||
|
// Tooltip state with 120ms enter delay, matching reference (delayDuration=120)
|
||||||
|
const [hover, setHover] = useState<HoverState | null>(null);
|
||||||
|
const enterTimer = useRef<number | null>(null);
|
||||||
|
const leaveTimer = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (enterTimer.current) window.clearTimeout(enterTimer.current);
|
||||||
|
if (leaveTimer.current) window.clearTimeout(leaveTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showTooltip = (cell: HeatmapCell, e?: MouseEvent) => {
|
||||||
|
if (leaveTimer.current) {
|
||||||
|
window.clearTimeout(leaveTimer.current);
|
||||||
|
leaveTimer.current = null;
|
||||||
|
}
|
||||||
|
enterTimer.current = window.setTimeout(() => {
|
||||||
|
if (e) {
|
||||||
|
setHover({ cell, x: e.clientX, y: e.clientY });
|
||||||
|
} else {
|
||||||
|
// Keyboard focus path — fall back to last known position
|
||||||
|
setHover((prev) => (prev ? { ...prev, cell } : { cell, x: 0, y: 0 }));
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveTooltip = (cell: HeatmapCell, e: MouseEvent) => {
|
||||||
|
if (hover && hover.cell.date === cell.date) {
|
||||||
|
setHover({ cell, x: e.clientX, y: e.clientY });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showTooltipFromFocus = (cell: HeatmapCell) => showTooltip(cell);
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
if (enterTimer.current) {
|
||||||
|
window.clearTimeout(enterTimer.current);
|
||||||
|
enterTimer.current = null;
|
||||||
|
}
|
||||||
|
leaveTimer.current = window.setTimeout(() => {
|
||||||
|
setHover(null);
|
||||||
|
}, 60);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cells.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-28 items-center justify-center text-sm text-gray-500">
|
||||||
|
{t("noData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Fixed-size year strip, centered; scrolls horizontally only when the card is narrower than the strip */}
|
||||||
|
<div className="overflow-x-auto pb-1">
|
||||||
|
<div
|
||||||
|
className="mx-auto w-fit"
|
||||||
|
style={{ minWidth: LABEL_W + weekCount * (CELL + GAP) }}
|
||||||
|
>
|
||||||
|
{/* month labels */}
|
||||||
|
<div
|
||||||
|
className="mb-1 grid"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `${LABEL_W}px repeat(${weekCount}, ${CELL}px)`,
|
||||||
|
columnGap: GAP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
{Array.from({ length: weekCount }, (_, wi) => {
|
||||||
|
const label = monthLabels.find((m) => m.weekIndex === wi);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={wi}
|
||||||
|
className="text-[10px] leading-none text-gray-500 whitespace-nowrap overflow-visible"
|
||||||
|
>
|
||||||
|
{label ? label.label : ""}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex" style={{ gap: GAP }}>
|
||||||
|
{/* dow labels */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col pr-0"
|
||||||
|
style={{ width: LABEL_W, gap: GAP }}
|
||||||
|
>
|
||||||
|
{dowLabels.map((d, i) => (
|
||||||
|
<div
|
||||||
|
key={d}
|
||||||
|
className={`flex items-center text-[10px] text-gray-500 ${
|
||||||
|
i % 2 === 1 ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
style={{ height: CELL }}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* cells */}
|
||||||
|
<div className="flex" style={{ gap: GAP }}>
|
||||||
|
{grid.map((week, wi) => (
|
||||||
|
<div key={wi} className="flex flex-col" style={{ gap: GAP }}>
|
||||||
|
{week.map((cell, di) => {
|
||||||
|
if (!cell) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${wi}-${di}`}
|
||||||
|
className="rounded-[3px] bg-transparent"
|
||||||
|
style={{ width: CELL, height: CELL }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const level = cell.level ?? 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cell.date}
|
||||||
|
type="button"
|
||||||
|
aria-label={cell.date}
|
||||||
|
onMouseEnter={(e) => showTooltip(cell, e)}
|
||||||
|
onMouseMove={(e) => moveTooltip(cell, e)}
|
||||||
|
onMouseLeave={hideTooltip}
|
||||||
|
onFocus={() => showTooltipFromFocus(cell)}
|
||||||
|
onBlur={hideTooltip}
|
||||||
|
className={`rounded-[3px] ring-1 ring-inset ring-white/5 transition-transform hover:scale-[1.35] focus:scale-[1.35] outline-none ${LEVEL_BG[level] || LEVEL_BG[0]}`}
|
||||||
|
style={{ width: CELL, height: CELL }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 text-[11px] text-gray-500">
|
||||||
|
<span>
|
||||||
|
{heatmap.start} → {heatmap.end}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span>{t("heatLess")}</span>
|
||||||
|
{[0, 1, 2, 3, 4].map((lv) => (
|
||||||
|
<div
|
||||||
|
key={lv}
|
||||||
|
className={`h-3 w-3 rounded-[3px] ring-1 ring-inset ring-white/5 ${LEVEL_BG[lv]}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span>{t("heatMore")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hover &&
|
||||||
|
createPortal(
|
||||||
|
<HeatmapTooltip state={hover} />,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeatmapTooltip({ state }: { state: HoverState }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { cell, x, y } = state;
|
||||||
|
// Position above the cursor with a small offset; flip below if too close to top.
|
||||||
|
const offset = 14;
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
position: "fixed",
|
||||||
|
top: y - offset,
|
||||||
|
left: x,
|
||||||
|
transform: "translate(-50%, -100%)",
|
||||||
|
zIndex: 9999,
|
||||||
|
pointerEvents: "none",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={style}
|
||||||
|
className="rounded-md border border-gray-700 bg-gray-900 px-3 py-1.5 text-xs text-gray-100 shadow-xl"
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-gray-50">{cell.date}</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
{fmtTokens(cell.totalTokens)} · {fmtUsd(cell.costUsd)} ·{" "}
|
||||||
|
{fmtInt(cell.requests)} {t("requests")}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
{t("cacheHitRate")}: {fmtPct(cell.cacheHitRate)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local int formatter (same style as DailyBars in this project)
|
||||||
|
function fmtInt(n: number): string {
|
||||||
|
return Math.round(n).toLocaleString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useSessions } from "../../hooks/useSessions";
|
||||||
|
import { fmtInt, fmtTime } from "../../lib/dashboard-format";
|
||||||
|
import { useTranslation } from "../../i18n";
|
||||||
|
import type { SessionRow, WorkspaceRow } from "../../types/sessions";
|
||||||
|
|
||||||
|
function fmtBytes(n: number): string {
|
||||||
|
const v = Number(n) || 0;
|
||||||
|
if (v < 1024) return `${v} B`;
|
||||||
|
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`;
|
||||||
|
if (v < 1024 * 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(v / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTime(value: string | number | null | undefined): number {
|
||||||
|
if (value == null || value === "") return 0;
|
||||||
|
if (typeof value === "number") return value > 1e12 ? value : value * 1000;
|
||||||
|
const ms = Date.parse(value);
|
||||||
|
return Number.isFinite(ms) ? ms : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowKey(row: SessionRow): string {
|
||||||
|
return `${row.workspaceId}/${row.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmState =
|
||||||
|
| { mode: "one"; row: SessionRow }
|
||||||
|
| { mode: "bulk"; rows: SessionRow[] }
|
||||||
|
| { mode: "workspace"; workspace: WorkspaceRow }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
type PreviewState = {
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
messages: { role: string; time: number | null; text: string }[];
|
||||||
|
truncated: boolean;
|
||||||
|
row: SessionRow;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export function SessionsPage() {
|
||||||
|
const { t, lang } = useTranslation();
|
||||||
|
const locale = lang === "zh" ? "zh" : "en";
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
setStatus,
|
||||||
|
workspace,
|
||||||
|
setWorkspace,
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
refresh,
|
||||||
|
archiveSession,
|
||||||
|
unarchiveSession,
|
||||||
|
deleteSession,
|
||||||
|
deleteWorkspace,
|
||||||
|
getPreview,
|
||||||
|
} = useSessions();
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||||
|
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||||
|
const [preview, setPreview] = useState<PreviewState>(null);
|
||||||
|
|
||||||
|
const workspaces = data?.workspaces ?? [];
|
||||||
|
const sessions = data?.sessions ?? [];
|
||||||
|
|
||||||
|
const workspaceMap = useMemo(() => {
|
||||||
|
const m = new Map<string, WorkspaceRow>();
|
||||||
|
for (const w of workspaces) m.set(w.id, w);
|
||||||
|
return m;
|
||||||
|
}, [workspaces]);
|
||||||
|
|
||||||
|
const allKeys = useMemo(() => sessions.map(rowKey), [sessions]);
|
||||||
|
const allSelected = allKeys.length > 0 && allKeys.every((k) => selected.has(k));
|
||||||
|
const someSelected = allKeys.some((k) => selected.has(k)) && !allSelected;
|
||||||
|
|
||||||
|
const toggleSelect = (row: SessionRow) => {
|
||||||
|
const key = rowKey(row);
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (allSelected) setSelected(new Set());
|
||||||
|
else setSelected(new Set(allKeys));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onArchive = async (row: SessionRow) => {
|
||||||
|
const key = rowKey(row);
|
||||||
|
setBusyId(key);
|
||||||
|
try {
|
||||||
|
await archiveSession(row.workspaceId, row.id);
|
||||||
|
setSelected(new Set());
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUnarchive = async (row: SessionRow) => {
|
||||||
|
const key = rowKey(row);
|
||||||
|
setBusyId(key);
|
||||||
|
try {
|
||||||
|
await unarchiveSession(row.workspaceId, row.id);
|
||||||
|
setSelected(new Set());
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPreview = async (row: SessionRow) => {
|
||||||
|
setPreview({
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
messages: [],
|
||||||
|
truncated: false,
|
||||||
|
row,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await getPreview(row.workspaceId, row.id, row.status);
|
||||||
|
setPreview({
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
messages: res.messages,
|
||||||
|
truncated: res.truncated,
|
||||||
|
row,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setPreview({
|
||||||
|
loading: false,
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
messages: [],
|
||||||
|
truncated: false,
|
||||||
|
row,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkArchive = async () => {
|
||||||
|
const rows = sessions.filter((s) => selected.has(rowKey(s)) && s.status === "active");
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
await archiveSession(row.workspaceId, row.id);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelected(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkUnarchive = async () => {
|
||||||
|
const rows = sessions.filter((s) => selected.has(rowKey(s)) && s.status === "archived");
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
await unarchiveSession(row.workspaceId, row.id);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelected(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const runConfirm = async () => {
|
||||||
|
if (!confirm) return;
|
||||||
|
setConfirmBusy(true);
|
||||||
|
try {
|
||||||
|
if (confirm.mode === "one") {
|
||||||
|
await deleteSession(confirm.row.workspaceId, confirm.row.id, confirm.row.status);
|
||||||
|
} else if (confirm.mode === "bulk") {
|
||||||
|
for (const row of confirm.rows) {
|
||||||
|
await deleteSession(row.workspaceId, row.id, row.status);
|
||||||
|
}
|
||||||
|
} else if (confirm.mode === "workspace") {
|
||||||
|
await deleteWorkspace(confirm.workspace.id);
|
||||||
|
if (workspace === confirm.workspace.id) setWorkspace("all");
|
||||||
|
}
|
||||||
|
setConfirm(null);
|
||||||
|
setSelected(new Set());
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setConfirmBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalSessions = workspaces.reduce(
|
||||||
|
(a, w) => a + (w.activeCount || 0) + (w.archivedCount || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden p-4 space-y-3">
|
||||||
|
<div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[#e5e5e7]">{t("sessionsTitle")}</h2>
|
||||||
|
<p className="mt-1 max-w-[60ch] text-sm text-gray-500">{t("sessionsSubtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => refresh()}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-3 py-1.5 text-sm rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? t("refreshing") : t("refresh")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="shrink-0 rounded-lg border border-red-500/40 bg-red-900/20 px-4 py-3 text-sm text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[260px_1fr]">
|
||||||
|
{/* Workspace rail */}
|
||||||
|
<div className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-[#2a2a2e] bg-[#16161a]">
|
||||||
|
<div className="shrink-0 border-b border-[#2a2a2e] px-4 py-2.5">
|
||||||
|
<div className="text-sm font-medium text-[#e5e5e7]">{t("workspaces")}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{t("workspaceIsolatedHint")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col p-2 overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWorkspace("all")}
|
||||||
|
className={`flex w-full shrink-0 items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
workspace === "all"
|
||||||
|
? "bg-blue-600/20 text-[#e5e5e7]"
|
||||||
|
: "text-gray-400 hover:bg-[#1f1f23] hover:text-[#e5e5e7]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{t("allWorkspaces")}</span>
|
||||||
|
<span className="text-xs tabular-nums">{fmtInt(totalSessions, locale)}</span>
|
||||||
|
</button>
|
||||||
|
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto mt-1">
|
||||||
|
{workspaces.map((w) => {
|
||||||
|
const total = (w.activeCount || 0) + (w.archivedCount || 0);
|
||||||
|
const isEmpty = total === 0 || w.empty;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={w.id}
|
||||||
|
className={`group flex items-start gap-1 rounded-md ${
|
||||||
|
workspace === w.id ? "bg-blue-600/20" : "hover:bg-[#1f1f23]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWorkspace(w.id)}
|
||||||
|
className={`flex min-w-0 flex-1 flex-col gap-0.5 px-3 py-2 text-left ${
|
||||||
|
workspace === w.id ? "text-[#e5e5e7]" : "text-gray-400 group-hover:text-[#e5e5e7]"
|
||||||
|
}`}
|
||||||
|
title={w.root || w.id}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">{w.name || w.id}</span>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums">
|
||||||
|
{isEmpty ? (
|
||||||
|
<span className="rounded border border-[#2a2a2e] px-1.5 py-0 text-[10px] text-gray-500">
|
||||||
|
{t("emptyWorkspace")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{w.activeCount || 0}
|
||||||
|
{w.archivedCount ? `/${w.archivedCount}` : ""}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{w.root && (
|
||||||
|
<span className="truncate font-mono text-[10px] opacity-70">{w.root}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isEmpty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-1 mr-1 h-7 w-7 shrink-0 rounded text-red-400/80 opacity-70 hover:bg-red-900/30 hover:text-red-300 group-hover:opacity-100"
|
||||||
|
title={t("deleteWorkspace")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfirm({ mode: "workspace", workspace: w });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!workspaces.length && !loading && (
|
||||||
|
<div className="px-3 py-6 text-center text-xs text-gray-500">{t("noSessions")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sessions table */}
|
||||||
|
<div className="flex min-h-0 flex-col space-y-3 overflow-hidden">
|
||||||
|
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center bg-[#1f1f23] border border-[#2a2a2e] rounded p-0.5">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["active", t("statusActive")],
|
||||||
|
["archived", t("statusArchived")],
|
||||||
|
["all", t("statusAll")],
|
||||||
|
] as const
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStatus(value);
|
||||||
|
setSelected(new Set());
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||||
|
status === value
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "text-gray-400 hover:text-[#e5e5e7]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleSelectAll}
|
||||||
|
className="px-2 py-1 text-xs text-gray-400 hover:text-[#e5e5e7]"
|
||||||
|
>
|
||||||
|
{allSelected ? t("deselectAll") : t("selectAll")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{t("selectedCount", { n: String(selected.size) })}
|
||||||
|
</span>
|
||||||
|
{status !== "archived" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={bulkArchive}
|
||||||
|
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529]"
|
||||||
|
>
|
||||||
|
{t("archive")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{status !== "active" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={bulkUnarchive}
|
||||||
|
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529]"
|
||||||
|
>
|
||||||
|
{t("unarchive")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const rows = sessions.filter((s) => selected.has(rowKey(s)));
|
||||||
|
if (rows.length) setConfirm({ mode: "bulk", rows });
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 text-xs rounded border border-red-500/30 text-red-300 hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
{t("delete")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
value={workspace}
|
||||||
|
onChange={(e) => setWorkspace(e.target.value)}
|
||||||
|
className="bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none max-w-[200px]"
|
||||||
|
>
|
||||||
|
<option value="all">{t("allWorkspaces")}</option>
|
||||||
|
{workspaces.map((w) => (
|
||||||
|
<option key={w.id} value={w.id}>
|
||||||
|
{w.name || w.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-[#2a2a2e] bg-[#16161a]">
|
||||||
|
<div className="shrink-0 border-b border-[#2a2a2e] px-4 py-2.5">
|
||||||
|
<div className="text-sm font-medium text-[#e5e5e7]">
|
||||||
|
{workspace === "all"
|
||||||
|
? t("allWorkspaces")
|
||||||
|
: workspaceMap.get(workspace)?.name || workspace}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{t("sessionsInView", { n: String(sessions.length) })}
|
||||||
|
{workspace !== "all" ? ` · ${t("isolatedToWorkspace")}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 z-10 bg-[#16161a] text-left text-gray-500 border-b border-[#2a2a2e]">
|
||||||
|
<tr>
|
||||||
|
<th className="w-10 px-3 py-2 font-normal">
|
||||||
|
<input
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) el.indeterminate = someSelected;
|
||||||
|
}}
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 accent-blue-600"
|
||||||
|
checked={allSelected}
|
||||||
|
disabled={!sessions.length}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
aria-label={t("selectAll")}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="px-2 py-2 font-normal">{t("sessionTitle")}</th>
|
||||||
|
<th className="px-2 py-2 font-normal">{t("workspace")}</th>
|
||||||
|
<th className="px-2 py-2 font-normal">{t("status")}</th>
|
||||||
|
<th className="px-2 py-2 font-normal text-right">{t("size")}</th>
|
||||||
|
<th className="px-2 py-2 font-normal">{t("updated")}</th>
|
||||||
|
<th className="px-2 py-2 font-normal text-right">{t("actions")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-10 text-center text-gray-500">
|
||||||
|
{loading ? t("scanning") : t("noSessions")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
sessions.map((row) => {
|
||||||
|
const key = rowKey(row);
|
||||||
|
const ws = workspaceMap.get(row.workspaceId);
|
||||||
|
const busy = busyId === key;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={key}
|
||||||
|
className="border-b border-[#2a2a2e]/50 hover:bg-[#1c1c20]"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 accent-blue-600"
|
||||||
|
checked={selected.has(key)}
|
||||||
|
onChange={() => toggleSelect(row)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 max-w-[280px]">
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="truncate text-left font-medium text-[#e5e5e7] hover:text-blue-400 hover:underline"
|
||||||
|
title={row.title || t("openPreview")}
|
||||||
|
onClick={() => openPreview(row)}
|
||||||
|
>
|
||||||
|
{row.title || row.id}
|
||||||
|
</button>
|
||||||
|
<span className="truncate font-mono text-[10px] text-gray-500" title={row.id}>
|
||||||
|
{row.id}
|
||||||
|
</span>
|
||||||
|
{row.workDir && (
|
||||||
|
<span
|
||||||
|
className="truncate font-mono text-[10px] text-gray-500/80"
|
||||||
|
title={row.workDir}
|
||||||
|
>
|
||||||
|
{row.workDir}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 max-w-[160px]">
|
||||||
|
<span className="truncate text-sm text-gray-400">
|
||||||
|
{ws?.name || row.workspaceId}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 whitespace-nowrap">
|
||||||
|
{row.status === "archived" ? (
|
||||||
|
<span className="rounded-md bg-amber-900/30 text-amber-300 px-2 py-0.5 text-xs">
|
||||||
|
{t("statusArchived")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-md bg-emerald-900/30 text-emerald-300 px-2 py-0.5 text-xs">
|
||||||
|
{t("statusActive")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right tabular-nums text-gray-400">
|
||||||
|
{fmtBytes(row.bytes)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 whitespace-nowrap tabular-nums text-gray-400">
|
||||||
|
{fmtTime(parseTime(row.updatedAt), locale)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{row.status === "active" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onArchive(row)}
|
||||||
|
title={t("archive")}
|
||||||
|
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("archive")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onUnarchive(row)}
|
||||||
|
title={t("unarchive")}
|
||||||
|
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("unarchive")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setConfirm({ mode: "one", row })}
|
||||||
|
title={t("delete")}
|
||||||
|
className="px-2 py-1 text-xs rounded border border-red-500/30 text-red-300 hover:bg-red-900/20 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("delete")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attribution */}
|
||||||
|
<div className="text-xs text-gray-500 pb-2">
|
||||||
|
{data?.home && <>{data.home}</>}
|
||||||
|
<br />
|
||||||
|
会话管理功能基于{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/JochenYang/kimicode-dashboard"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-500 hover:text-blue-400 underline"
|
||||||
|
>
|
||||||
|
kimicode-dashboard
|
||||||
|
</a>{" "}
|
||||||
|
(MIT,© JochenYang)移植
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm dialog */}
|
||||||
|
{confirm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||||
|
<div className="w-full max-w-md rounded-xl border border-[#2a2a2e] bg-[#16161a] shadow-xl">
|
||||||
|
<div className="space-y-3 p-5">
|
||||||
|
<h3 className="text-base font-semibold text-[#e5e5e7]">
|
||||||
|
{confirm.mode === "bulk"
|
||||||
|
? t("confirmBulkTitle")
|
||||||
|
: confirm.mode === "workspace"
|
||||||
|
? t("confirmDeleteWorkspaceTitle")
|
||||||
|
: t("confirmDeleteTitle")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{confirm.mode === "bulk"
|
||||||
|
? t("confirmBulkDelete", { n: String(confirm.rows.length) })
|
||||||
|
: confirm.mode === "workspace"
|
||||||
|
? t("confirmDeleteWorkspace")
|
||||||
|
: t("confirmDeleteSession")}
|
||||||
|
</p>
|
||||||
|
{confirm.mode === "one" && (
|
||||||
|
<div className="rounded-md border border-[#2a2a2e] bg-[#1f1f23] px-3 py-2.5">
|
||||||
|
<div className="truncate text-sm font-medium">
|
||||||
|
{confirm.row.title || confirm.row.id}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">
|
||||||
|
{confirm.row.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{confirm.mode === "bulk" && (
|
||||||
|
<div className="rounded-md border border-[#2a2a2e] bg-[#1f1f23] px-3 py-2.5 text-sm text-gray-400">
|
||||||
|
{t("selectedCount", { n: String(confirm.rows.length) })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{confirm.mode === "workspace" && (
|
||||||
|
<div className="rounded-md border border-[#2a2a2e] bg-[#1f1f23] px-3 py-2.5">
|
||||||
|
<div className="truncate text-sm font-medium">
|
||||||
|
{confirm.workspace.name || confirm.workspace.id}
|
||||||
|
</div>
|
||||||
|
{confirm.workspace.root && (
|
||||||
|
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">
|
||||||
|
{confirm.workspace.root}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 font-mono text-[11px] text-gray-500">
|
||||||
|
{confirm.workspace.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-[#2a2a2e] bg-[#121214] px-5 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={confirmBusy}
|
||||||
|
onClick={() => setConfirm(null)}
|
||||||
|
className="px-3 py-1.5 text-sm rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={confirmBusy}
|
||||||
|
onClick={runConfirm}
|
||||||
|
className="px-3 py-1.5 text-sm rounded bg-red-600 text-white hover:bg-red-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{confirmBusy
|
||||||
|
? t("refreshing")
|
||||||
|
: confirm.mode === "workspace"
|
||||||
|
? t("deleteWorkspace")
|
||||||
|
: t("deleteForever")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview dialog */}
|
||||||
|
{preview && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||||
|
<div className="flex w-full max-w-2xl max-h-[min(80vh,36rem)] flex-col overflow-hidden rounded-xl border border-[#2a2a2e] bg-[#16161a] shadow-xl">
|
||||||
|
<div className="shrink-0 space-y-2 border-b border-[#2a2a2e] p-5">
|
||||||
|
<h3 className="text-base font-semibold text-[#e5e5e7]">{t("previewTitle")}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{t("previewHint")}</p>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
<div className="truncate font-medium text-[#e5e5e7]">
|
||||||
|
{preview.row.title || preview.row.id}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 break-all font-mono opacity-80">{preview.row.id}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-5">
|
||||||
|
{preview.loading && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-10 text-sm text-gray-500">
|
||||||
|
<div className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full" />
|
||||||
|
{t("scanning")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{preview.error && (
|
||||||
|
<div className="rounded-md border border-red-500/40 bg-red-900/20 px-3 py-2 text-sm text-red-300">
|
||||||
|
{preview.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!preview.loading && !preview.error && !preview.messages.length && (
|
||||||
|
<div className="py-10 text-center text-sm text-gray-500">{t("previewEmpty")}</div>
|
||||||
|
)}
|
||||||
|
{preview.messages.map((m, i) => {
|
||||||
|
const roleLabel =
|
||||||
|
m.role === "user"
|
||||||
|
? t("roleUser")
|
||||||
|
: m.role === "assistant"
|
||||||
|
? t("roleAssistant")
|
||||||
|
: m.role === "system"
|
||||||
|
? t("roleSystem")
|
||||||
|
: m.role;
|
||||||
|
return (
|
||||||
|
<PreviewMessageCard
|
||||||
|
key={`${m.role}-${i}`}
|
||||||
|
role={m.role}
|
||||||
|
roleLabel={roleLabel}
|
||||||
|
time={m.time}
|
||||||
|
text={m.text}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{preview.truncated && (
|
||||||
|
<div className="text-center text-xs text-gray-500">{t("previewTruncated")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 flex justify-end border-t border-[#2a2a2e] bg-[#121214] px-5 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPreview(null)}
|
||||||
|
className="px-3 py-1.5 text-sm rounded border border-[#2a2a2e] hover:bg-[#252529]"
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-message card with 500-char collapse + expand toggle.
|
||||||
|
// Renders long texts lazily — prevents huge <pre> blocks from bloating the DOM.
|
||||||
|
const PREVIEW_TEXT_LIMIT = 500;
|
||||||
|
|
||||||
|
function PreviewMessageCard({
|
||||||
|
role,
|
||||||
|
roleLabel,
|
||||||
|
time,
|
||||||
|
text,
|
||||||
|
locale,
|
||||||
|
}: {
|
||||||
|
role: string;
|
||||||
|
roleLabel: string;
|
||||||
|
time: number | null;
|
||||||
|
text: string;
|
||||||
|
locale: string;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const isLong = text.length > PREVIEW_TEXT_LIMIT;
|
||||||
|
const visible = expanded || !isLong ? text : text.slice(0, PREVIEW_TEXT_LIMIT);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border border-[#2a2a2e] px-3 py-2.5 ${
|
||||||
|
role === "user"
|
||||||
|
? "bg-blue-600/10"
|
||||||
|
: role === "assistant"
|
||||||
|
? "bg-[#1f1f23]"
|
||||||
|
: "bg-[#16161a]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2 text-[11px] text-gray-500">
|
||||||
|
<span className="font-medium text-gray-300">{roleLabel}</span>
|
||||||
|
{time ? (
|
||||||
|
<span className="tabular-nums">{fmtTime(time, locale)}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-gray-200">
|
||||||
|
{visible}
|
||||||
|
{!expanded && isLong && (
|
||||||
|
<span className="text-gray-500"> …</span>
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
{isLong && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="mt-1 text-[11px] text-blue-400 hover:text-blue-300 hover:underline"
|
||||||
|
>
|
||||||
|
{expanded
|
||||||
|
? `收起 · ${text.length.toLocaleString()} 字符`
|
||||||
|
: `展开全文 · ${text.length.toLocaleString()} 字符`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type { SummaryResult } from "../types/dashboard";
|
||||||
|
|
||||||
|
export type DashboardRange = "today" | "7d" | "30d" | "all";
|
||||||
|
|
||||||
|
const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range";
|
||||||
|
|
||||||
|
export function useDashboard() {
|
||||||
|
const [range, setRange] = useState<DashboardRange>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null;
|
||||||
|
if (stored === "today" || stored === "7d" || stored === "30d" || stored === "all") {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return "30d";
|
||||||
|
});
|
||||||
|
|
||||||
|
const [data, setData] = useState<SummaryResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await invoke<SummaryResult>("get_summary", { range });
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const changeRange = useCallback((next: DashboardRange) => {
|
||||||
|
setRange(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RANGE_STORAGE_KEY, next);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { range, changeRange, data, loading, error, refresh };
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type {
|
||||||
|
ActionResponse,
|
||||||
|
PreviewResult,
|
||||||
|
SessionStatusFilter,
|
||||||
|
SessionsResult,
|
||||||
|
} from "../types/sessions";
|
||||||
|
|
||||||
|
export function useSessions() {
|
||||||
|
const [status, setStatus] = useState<SessionStatusFilter>("active");
|
||||||
|
const [workspace, setWorkspace] = useState<string>("all");
|
||||||
|
const [data, setData] = useState<SessionsResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await invoke<SessionsResult>("list_sessions", {
|
||||||
|
status,
|
||||||
|
workspace: workspace === "all" ? null : workspace,
|
||||||
|
});
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
setData(null);
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [status, workspace]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const archiveSession = useCallback(
|
||||||
|
async (workspaceId: string, sessionId: string) => {
|
||||||
|
await invoke<ActionResponse>("archive_session", { workspaceId, sessionId });
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[refresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const unarchiveSession = useCallback(
|
||||||
|
async (workspaceId: string, sessionId: string) => {
|
||||||
|
await invoke<ActionResponse>("unarchive_session", {
|
||||||
|
workspaceId,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[refresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteSession = useCallback(
|
||||||
|
async (workspaceId: string, sessionId: string, sessionStatus?: string) => {
|
||||||
|
await invoke<ActionResponse>("delete_session", {
|
||||||
|
workspaceId,
|
||||||
|
sessionId,
|
||||||
|
status: sessionStatus ?? null,
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[refresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteWorkspace = useCallback(
|
||||||
|
async (workspaceId: string) => {
|
||||||
|
await invoke<ActionResponse>("delete_workspace", {
|
||||||
|
workspaceId,
|
||||||
|
confirm: true,
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[refresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getPreview = useCallback(
|
||||||
|
async (workspaceId: string, sessionId: string, sessionStatus?: string) => {
|
||||||
|
return invoke<PreviewResult>("get_session_preview", {
|
||||||
|
workspaceId,
|
||||||
|
sessionId,
|
||||||
|
status: sessionStatus ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
setStatus,
|
||||||
|
workspace,
|
||||||
|
setWorkspace,
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
refresh,
|
||||||
|
archiveSession,
|
||||||
|
unarchiveSession,
|
||||||
|
deleteSession,
|
||||||
|
deleteWorkspace,
|
||||||
|
getPreview,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
|
|
||||||
// Provider list
|
// Provider list
|
||||||
providers: "Providers",
|
providers: "Providers",
|
||||||
|
dashboard: "Dashboard",
|
||||||
|
sessions: "Sessions",
|
||||||
addProvider: "Add Provider",
|
addProvider: "Add Provider",
|
||||||
noProviders: "No providers yet",
|
noProviders: "No providers yet",
|
||||||
addFirstProvider: "Add first provider",
|
addFirstProvider: "Add first provider",
|
||||||
@@ -131,6 +133,71 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
capToolUse: "Tool Use",
|
capToolUse: "Tool Use",
|
||||||
customCapabilities: "Other capabilities (comma-separated)",
|
customCapabilities: "Other capabilities (comma-separated)",
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
sessionsTitle: "Sessions",
|
||||||
|
sessionsSubtitle:
|
||||||
|
"Browse, preview, archive, or delete Kimi Code sessions by workspace. Archiving keeps data on disk.",
|
||||||
|
workspaces: "Workspaces",
|
||||||
|
workspaceIsolatedHint: "Sessions are isolated per workspace",
|
||||||
|
allWorkspaces: "All workspaces",
|
||||||
|
emptyWorkspace: "Empty",
|
||||||
|
deleteWorkspace: "Delete workspace",
|
||||||
|
noSessions: "No sessions",
|
||||||
|
statusActive: "Active",
|
||||||
|
statusArchived: "Archived",
|
||||||
|
statusAll: "All",
|
||||||
|
selectAll: "Select all",
|
||||||
|
deselectAll: "Deselect all",
|
||||||
|
selectedCount: "{n} selected",
|
||||||
|
archive: "Archive",
|
||||||
|
unarchive: "Unarchive",
|
||||||
|
sessionTitle: "Session",
|
||||||
|
workspace: "Workspace",
|
||||||
|
status: "Status",
|
||||||
|
size: "Size",
|
||||||
|
updated: "Updated",
|
||||||
|
actions: "Actions",
|
||||||
|
sessionsInView: "{n} sessions",
|
||||||
|
isolatedToWorkspace: "this workspace only",
|
||||||
|
openPreview: "Open preview",
|
||||||
|
scanning: "Scanning…",
|
||||||
|
refresh: "Refresh",
|
||||||
|
refreshing: "Refreshing…",
|
||||||
|
refreshed: "Refreshed",
|
||||||
|
cancel: "Cancel",
|
||||||
|
confirmDeleteTitle: "Delete session forever?",
|
||||||
|
confirmDeleteSession:
|
||||||
|
"This cannot be undone. Prefer archive if you only want it out of the way.",
|
||||||
|
confirmBulkTitle: "Delete selected forever?",
|
||||||
|
confirmBulkDelete: "Permanently delete {n} sessions. This cannot be undone.",
|
||||||
|
confirmDeleteWorkspaceTitle: "Delete empty workspace?",
|
||||||
|
confirmDeleteWorkspace: "Only workspaces with no sessions can be removed.",
|
||||||
|
deleteForever: "Delete forever",
|
||||||
|
previewTitle: "Session preview",
|
||||||
|
previewHint:
|
||||||
|
"Roles and truncated text only — secrets and raw tool dumps are omitted.",
|
||||||
|
previewEmpty: "No previewable messages",
|
||||||
|
previewTruncated: "Preview truncated",
|
||||||
|
roleUser: "User",
|
||||||
|
roleAssistant: "Assistant",
|
||||||
|
roleSystem: "System",
|
||||||
|
|
||||||
|
// Heatmap
|
||||||
|
noData: "No data",
|
||||||
|
heatLess: "Less",
|
||||||
|
heatMore: "More",
|
||||||
|
requests: "Requests",
|
||||||
|
cacheHitRate: "Cache hit rate",
|
||||||
|
|
||||||
|
// Daily detail modal
|
||||||
|
dayDetail: "Daily usage detail",
|
||||||
|
close: "Close",
|
||||||
|
totalTokens: "Total tokens",
|
||||||
|
cost: "Cost",
|
||||||
|
modelDistribution: "Model distribution",
|
||||||
|
modalEscHint: "Press Esc or click outside to close",
|
||||||
|
doubleClickHint: "Double-click for details",
|
||||||
|
|
||||||
// Languages
|
// Languages
|
||||||
language: "Language",
|
language: "Language",
|
||||||
zh: "中文",
|
zh: "中文",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const zhTranslations = {
|
|||||||
|
|
||||||
// Provider list
|
// Provider list
|
||||||
providers: "供应商",
|
providers: "供应商",
|
||||||
|
dashboard: "用量仪表盘",
|
||||||
|
sessions: "会话管理",
|
||||||
addProvider: "添加供应商",
|
addProvider: "添加供应商",
|
||||||
noProviders: "暂无供应商",
|
noProviders: "暂无供应商",
|
||||||
addFirstProvider: "添加第一个供应商",
|
addFirstProvider: "添加第一个供应商",
|
||||||
@@ -129,6 +131,68 @@ export const zhTranslations = {
|
|||||||
capToolUse: "工具调用",
|
capToolUse: "工具调用",
|
||||||
customCapabilities: "其他能力(逗号分隔)",
|
customCapabilities: "其他能力(逗号分隔)",
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
sessionsTitle: "会话管理",
|
||||||
|
sessionsSubtitle: "按工作区查看、预览、归档或删除 Kimi Code 会话。归档不会删除数据。",
|
||||||
|
workspaces: "工作区",
|
||||||
|
workspaceIsolatedHint: "会话按工作区隔离存储",
|
||||||
|
allWorkspaces: "全部工作区",
|
||||||
|
emptyWorkspace: "空",
|
||||||
|
deleteWorkspace: "删除工作区",
|
||||||
|
noSessions: "暂无会话",
|
||||||
|
statusActive: "活跃",
|
||||||
|
statusArchived: "已归档",
|
||||||
|
statusAll: "全部",
|
||||||
|
selectAll: "全选",
|
||||||
|
deselectAll: "取消全选",
|
||||||
|
selectedCount: "已选 {n} 项",
|
||||||
|
archive: "归档",
|
||||||
|
unarchive: "取消归档",
|
||||||
|
sessionTitle: "会话",
|
||||||
|
workspace: "工作区",
|
||||||
|
status: "状态",
|
||||||
|
size: "大小",
|
||||||
|
updated: "更新时间",
|
||||||
|
actions: "操作",
|
||||||
|
sessionsInView: "共 {n} 个会话",
|
||||||
|
isolatedToWorkspace: "当前工作区",
|
||||||
|
openPreview: "打开预览",
|
||||||
|
scanning: "扫描中…",
|
||||||
|
refresh: "刷新",
|
||||||
|
refreshing: "刷新中…",
|
||||||
|
refreshed: "已刷新",
|
||||||
|
cancel: "取消",
|
||||||
|
confirmDeleteTitle: "永久删除会话?",
|
||||||
|
confirmDeleteSession: "删除后无法恢复。若只是暂时不用,请改用归档。",
|
||||||
|
confirmBulkTitle: "批量永久删除?",
|
||||||
|
confirmBulkDelete: "将永久删除 {n} 个会话,此操作无法撤销。",
|
||||||
|
confirmDeleteWorkspaceTitle: "删除空工作区?",
|
||||||
|
confirmDeleteWorkspace: "仅允许删除没有任何会话的工作区。",
|
||||||
|
deleteForever: "永久删除",
|
||||||
|
previewTitle: "会话预览",
|
||||||
|
previewHint: "仅展示角色与截断后的文本,不会暴露密钥或工具原始输出。",
|
||||||
|
previewEmpty: "没有可预览的消息",
|
||||||
|
previewTruncated: "预览已截断",
|
||||||
|
roleUser: "用户",
|
||||||
|
roleAssistant: "助手",
|
||||||
|
roleSystem: "系统",
|
||||||
|
|
||||||
|
// Heatmap
|
||||||
|
noData: "暂无数据",
|
||||||
|
heatLess: "少",
|
||||||
|
heatMore: "多",
|
||||||
|
requests: "请求",
|
||||||
|
cacheHitRate: "缓存命中率",
|
||||||
|
|
||||||
|
// Daily detail modal
|
||||||
|
dayDetail: "每日用量详情",
|
||||||
|
close: "关闭",
|
||||||
|
totalTokens: "总 Token",
|
||||||
|
cost: "费用",
|
||||||
|
modelDistribution: "模型用量分布",
|
||||||
|
modalEscHint: "按 Esc 或点击外部关闭",
|
||||||
|
doubleClickHint: "双击查看详情",
|
||||||
|
|
||||||
// Languages
|
// Languages
|
||||||
language: "语言",
|
language: "语言",
|
||||||
zh: "中文",
|
zh: "中文",
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Formatting helpers for the dashboard — ported from kimicode-dashboard/web/src/format.js
|
||||||
|
|
||||||
|
export function fmtInt(n: number, locale = "en"): string {
|
||||||
|
return new Intl.NumberFormat(locale === "zh" ? "zh-CN" : "en-US").format(
|
||||||
|
Math.round(Number(n) || 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTokens(n: number): string {
|
||||||
|
const v = Number(n) || 0;
|
||||||
|
if (v >= 1e9) return (v / 1e9).toFixed(2) + "B";
|
||||||
|
if (v >= 1e6) return (v / 1e6).toFixed(2) + "M";
|
||||||
|
if (v >= 1e3) return (v / 1e3).toFixed(1) + "K";
|
||||||
|
return String(Math.round(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtUsd(n: number): string {
|
||||||
|
const v = Number(n) || 0;
|
||||||
|
if (v === 0) return "$0.00";
|
||||||
|
if (v < 0.01) return "$" + v.toFixed(4);
|
||||||
|
if (v < 1) return "$" + v.toFixed(3);
|
||||||
|
return "$" + v.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtPct(n: number): string {
|
||||||
|
return ((Number(n) || 0) * 100).toFixed(1) + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTime(ms: number, locale = "en"): string {
|
||||||
|
if (!ms) return "—";
|
||||||
|
return new Date(ms).toLocaleString(locale === "zh" ? "zh-CN" : "en-US", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Dashboard types — match the Rust structs in src-tauri/src/dashboard.rs (camelCase serde)
|
||||||
|
|
||||||
|
export interface TotalsRow {
|
||||||
|
requests: number;
|
||||||
|
inputOther: number;
|
||||||
|
output: number;
|
||||||
|
inputCacheRead: number;
|
||||||
|
inputCacheCreation: number;
|
||||||
|
costUsd: number;
|
||||||
|
totalTokens: number;
|
||||||
|
cacheHitRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyRow extends TotalsRow {
|
||||||
|
date: string;
|
||||||
|
byModel: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelRow extends TotalsRow {
|
||||||
|
model: string;
|
||||||
|
modelDisplay: string;
|
||||||
|
modelResolved: string;
|
||||||
|
priceId: string;
|
||||||
|
costEstimated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentRow {
|
||||||
|
time: number;
|
||||||
|
model: string;
|
||||||
|
modelDisplay: string;
|
||||||
|
modelResolved: string;
|
||||||
|
inputOther: number;
|
||||||
|
output: number;
|
||||||
|
inputCacheRead: number;
|
||||||
|
inputCacheCreation: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costUsd: number;
|
||||||
|
costEstimated: boolean;
|
||||||
|
priceId: string;
|
||||||
|
fromEnv: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RangeStats {
|
||||||
|
range: string;
|
||||||
|
totals: TotalsRow;
|
||||||
|
daily: DailyRow[];
|
||||||
|
models: ModelRow[];
|
||||||
|
recent: RecentRow[];
|
||||||
|
recentTotal: number;
|
||||||
|
recentLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeatmapCell {
|
||||||
|
date: string;
|
||||||
|
dow: number;
|
||||||
|
weekIndex: number;
|
||||||
|
requests: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costUsd: number;
|
||||||
|
cacheHitRate: number;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthLabel {
|
||||||
|
weekIndex: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeatmapData {
|
||||||
|
weeks: number;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
maxTokens: number;
|
||||||
|
cells: HeatmapCell[];
|
||||||
|
monthLabels: MonthLabel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanMeta {
|
||||||
|
filesScanned: number;
|
||||||
|
linesSeen: number;
|
||||||
|
recordCount: number;
|
||||||
|
home: string;
|
||||||
|
sessionsRoot: string;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AllModelRow {
|
||||||
|
model: string;
|
||||||
|
modelDisplay: string;
|
||||||
|
requests: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costUsd: number;
|
||||||
|
costEstimated: boolean;
|
||||||
|
cacheHitRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryResult {
|
||||||
|
home: string;
|
||||||
|
valid: boolean;
|
||||||
|
scannedAt: number;
|
||||||
|
meta: ScanMeta;
|
||||||
|
range: string;
|
||||||
|
stats: RangeStats;
|
||||||
|
heatmap: HeatmapData;
|
||||||
|
allModels: AllModelRow[];
|
||||||
|
allModelCount: number;
|
||||||
|
rangeTotals: Record<string, TotalsRow>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Session management types — match Rust structs in dashboard.rs (camelCase serde)
|
||||||
|
|
||||||
|
export interface WorkspaceRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
root: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
lastOpenedAt: string | null;
|
||||||
|
activeCount: number;
|
||||||
|
archivedCount: number;
|
||||||
|
empty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRow {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
status: "active" | "archived" | string;
|
||||||
|
title: string | null;
|
||||||
|
workDir: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
bytes: number;
|
||||||
|
files: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionsResult {
|
||||||
|
home: string;
|
||||||
|
archiveRoot: string;
|
||||||
|
workspaces: WorkspaceRow[];
|
||||||
|
sessions: SessionRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionResponse {
|
||||||
|
ok: boolean;
|
||||||
|
workspaceId: string;
|
||||||
|
sessionId: string;
|
||||||
|
status?: string | null;
|
||||||
|
path?: string | null;
|
||||||
|
deleted?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewMessage {
|
||||||
|
role: string;
|
||||||
|
time: number | null;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewResult {
|
||||||
|
workspaceId: string;
|
||||||
|
sessionId: string;
|
||||||
|
status: string;
|
||||||
|
title: string | null;
|
||||||
|
workDir: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
messageCount: number;
|
||||||
|
truncated: boolean;
|
||||||
|
messages: PreviewMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionStatusFilter = "active" | "archived" | "all";
|
||||||
Reference in new issue
Block a user