feat(sessions): 一键归档 + 归档用量快照落库 — bump to v0.7.18
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s

- archive_sessions_before:按 updatedAt 截止点批量元数据归档(1月/半月/周/指定日期)
- 归档瞬间聚合 wire.jsonl 用量按天×模型快照进 SQLite archived_sessions 表
- 统计合并:文件已删除的归档会话由快照合成 UsageRecord 注入
  get_summary/日明细,趋势/热力图/模型统计全兼容且绝不双计
- models-dev 快照同步 2026-09-14(7784 模型);pricing 测试跟随
  deepseek-v4-flash 调价(0.15/0.60)
- cargo test 129 通过(含端到端归档→删目录→统计保留用例)、tsc 零错误
This commit is contained in:
KimiSwitch Dev committed 2026-09-14 01:52:21 +08:00
1 parent 00cf194b5c
commit 0f421b4e44
15 files changed
+6264 -943

No files matched your search

+189 -2
View File
@@ -1,8 +1,8 @@
import { useMemo, useState } from "react";
import { useEffect, 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";
import type { BulkArchiveResult, SessionRow, WorkspaceRow } from "../../types/sessions";
function fmtBytes(n: number): string {
const v = Number(n) || 0;
@@ -23,6 +23,44 @@ function rowKey(row: SessionRow): string {
return `${row.workspaceId}/${row.id}`;
}
type ArchiveAllMode = "month" | "half" | "week" | "custom";
type ArchiveAllState = {
mode: ArchiveAllMode;
/** YYYY-MM-DD, used when mode is "custom". */
date: string;
busy: boolean;
result: BulkArchiveResult | null;
error: string | null;
};
function localDateInput(d: Date): string {
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
}
/** Today minus one month — the default value of the custom-date picker. */
function defaultCustomDate(): string {
const d = new Date();
d.setMonth(d.getMonth() - 1);
return localDateInput(d);
}
/** Cutoff epoch ms for the selected option (custom dates start at local midnight). */
function cutoffFor(state: ArchiveAllState): number {
const now = new Date();
if (state.mode === "month" || state.mode === "half" || state.mode === "week") {
const d = new Date(now);
if (state.mode === "month") d.setMonth(d.getMonth() - 1);
if (state.mode === "half") d.setDate(d.getDate() - 15);
if (state.mode === "week") d.setDate(d.getDate() - 7);
return d.getTime();
}
const [y, m, day] = state.date.split("-").map(Number);
return new Date(y || now.getFullYear(), (m || 1) - 1, day || 1, 0, 0, 0, 0).getTime();
}
type ConfirmState =
| { mode: "one"; row: SessionRow }
| { mode: "bulk"; rows: SessionRow[] }
@@ -52,6 +90,7 @@ export function SessionsPage() {
refresh,
archiveSession,
unarchiveSession,
archiveBefore,
deleteSession,
deleteWorkspace,
getPreview,
@@ -62,6 +101,7 @@ export function SessionsPage() {
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [preview, setPreview] = useState<PreviewState>(null);
const [archiveAll, setArchiveAll] = useState<ArchiveAllState | null>(null);
const workspaces = data?.workspaces ?? [];
const sessions = data?.sessions ?? [];
@@ -199,6 +239,30 @@ export function SessionsPage() {
0
);
useEffect(() => {
if (!archiveAll) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setArchiveAll((s) => (s && s.busy ? s : null));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [archiveAll]);
const runArchiveAll = async () => {
if (!archiveAll || archiveAll.busy) return;
const cutoffMs = cutoffFor(archiveAll);
setArchiveAll((s) => (s ? { ...s, busy: true, result: null, error: null } : s));
try {
const res = await archiveBefore(cutoffMs);
setArchiveAll((s) => (s ? { ...s, busy: false, result: res } : s));
setSelected(new Set());
} catch (e) {
setArchiveAll((s) =>
s ? { ...s, busy: false, error: e instanceof Error ? e.message : String(e) } : s
);
}
};
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">
@@ -376,6 +440,23 @@ export function SessionsPage() {
</button>
</>
)}
{status !== "archived" && (
<button
type="button"
onClick={() =>
setArchiveAll({
mode: "month",
date: defaultCustomDate(),
busy: false,
result: null,
error: null,
})
}
className="px-2 py-1 text-xs rounded border border-border hover:bg-hover-2"
>
{t("sessionsArchiveAll")}
</button>
)}
<select
value={workspace}
onChange={(e) => setWorkspace(e.target.value)}
@@ -548,6 +629,112 @@ export function SessionsPage() {
{data?.home && <>{data.home}</>}
</div>
{/* Bulk archive dialog */}
{archiveAll && (
<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-border bg-panel shadow-xl">
<div className="space-y-3 p-5">
<h3 className="text-base font-semibold text-content-primary">
{t("archiveAllTitle")}
</h3>
<p className="text-sm text-content-muted">{t("archiveAllDesc")}</p>
<div className="space-y-0.5">
{(
[
["month", t("archiveOptionMonth")],
["half", t("archiveOptionHalfMonth")],
["week", t("archiveOptionWeek")],
["custom", t("archiveOptionCustom")],
] as const
).map(([mode, label]) => (
<label
key={mode}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-content-primary hover:bg-input"
>
<input
type="radio"
name="archiveAllMode"
className="h-4 w-4 accent-blue-600"
checked={archiveAll.mode === mode}
disabled={archiveAll.busy}
onChange={() =>
setArchiveAll((s) =>
s ? { ...s, mode, result: null, error: null } : s
)
}
/>
<span>{label}</span>
</label>
))}
</div>
{archiveAll.mode === "custom" && (
<input
type="date"
value={archiveAll.date}
disabled={archiveAll.busy}
onChange={(e) =>
setArchiveAll((s) => (s ? { ...s, date: e.target.value } : s))
}
className="bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
)}
<div className="rounded-md border border-border bg-input px-3 py-2.5 text-sm text-content-muted">
{t("archiveAllConfirm", {
date: new Date(cutoffFor(archiveAll)).toLocaleDateString(
locale === "zh" ? "zh-CN" : "en-US"
),
})}
</div>
{archiveAll.error && (
<div className="rounded-md border border-red-500/40 bg-red-900/20 px-3 py-2 text-sm text-red-300">
{t("archiveAllFailed")}: {archiveAll.error}
</div>
)}
{archiveAll.result && archiveAll.result.archived === 0 && (
<div className="rounded-md border border-border bg-input px-3 py-2 text-sm text-content-muted">
{t("archiveAllEmpty")}
</div>
)}
{archiveAll.result && archiveAll.result.archived > 0 && (
<div className="rounded-md border border-emerald-500/40 bg-emerald-900/20 px-3 py-2 text-sm text-emerald-300">
{t("archiveAllDone", {
archived: archiveAll.result.archived,
skipped: archiveAll.result.skipped,
})}
</div>
)}
{archiveAll.result && archiveAll.result.errors.length > 0 && (
<div className="space-y-1 rounded-md border border-red-500/40 bg-red-900/20 px-3 py-2 text-xs text-red-300">
{archiveAll.result.errors.slice(0, 3).map((err, i) => (
<div key={i} className="break-all">
{err}
</div>
))}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-border bg-footer px-5 py-3">
<button
type="button"
disabled={archiveAll.busy}
onClick={() => setArchiveAll(null)}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50"
>
{archiveAll.result ? t("close") : t("cancel")}
</button>
<button
type="button"
disabled={archiveAll.busy}
onClick={runArchiveAll}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50"
>
{archiveAll.busy ? t("archiveAllRunning") : t("sessionsArchiveAll")}
</button>
</div>
</div>
</div>
)}
{/* Confirm dialog */}
{confirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
+13
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import type {
ActionResponse,
BulkArchiveResult,
PreviewResult,
SessionStatusFilter,
SessionsResult,
@@ -54,6 +55,17 @@ export function useSessions() {
[refresh]
);
const archiveBefore = useCallback(
async (cutoffMs: number) => {
const result = await invoke<BulkArchiveResult>("archive_sessions_before", {
cutoffMs,
});
await refresh();
return result;
},
[refresh]
);
const deleteSession = useCallback(
async (workspaceId: string, sessionId: string, sessionStatus?: string) => {
await invoke<ActionResponse>("delete_session", {
@@ -100,6 +112,7 @@ export function useSessions() {
refresh,
archiveSession,
unarchiveSession,
archiveBefore,
deleteSession,
deleteWorkspace,
getPreview,
+13
View File
@@ -225,6 +225,19 @@ export const enTranslations: Record<TranslationKey, string> = {
roleUser: "User",
roleAssistant: "Assistant",
roleSystem: "System",
sessionsArchiveAll: "Archive old sessions",
archiveAllTitle: "Archive old sessions",
archiveAllDesc:
"Archives every active session whose last activity is older than the selected date. Files are kept and usage stats stay intact.",
archiveOptionMonth: "Older than 1 month",
archiveOptionHalfMonth: "Older than 15 days",
archiveOptionWeek: "Older than 1 week",
archiveOptionCustom: "Specific date",
archiveAllConfirm: "Archive every active session last active before {date}. Continue?",
archiveAllRunning: "Archiving…",
archiveAllDone: "Archived {archived} sessions, skipped {skipped}",
archiveAllFailed: "Archive failed",
archiveAllEmpty: "No sessions match",
// Heatmap
noData: "No data",
+12
View File
@@ -220,6 +220,18 @@ export const zhTranslations = {
roleUser: "用户",
roleAssistant: "助手",
roleSystem: "系统",
sessionsArchiveAll: "一键归档",
archiveAllTitle: "一键归档会话",
archiveAllDesc: "将归档最后活跃时间早于所选日期的全部活跃会话;文件保留,统计不受影响。",
archiveOptionMonth: "1 个月前",
archiveOptionHalfMonth: "半个月前",
archiveOptionWeek: "1 星期前",
archiveOptionCustom: "指定日期",
archiveAllConfirm: "将归档最后活跃时间早于 {date} 的全部活跃会话。确认?",
archiveAllRunning: "正在归档…",
archiveAllDone: "已归档 {archived} 个会话,跳过 {skipped} 个",
archiveAllFailed: "归档失败",
archiveAllEmpty: "没有符合条件的会话",
// Heatmap
noData: "暂无数据",
+2870 -492
View File
File diff suppressed because it is too large. Load diff
+2407 -382
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -39,6 +39,12 @@ export interface ActionResponse {
deleted?: boolean | null;
}
export interface BulkArchiveResult {
archived: number;
skipped: number;
errors: string[];
}
export interface PreviewMessage {
role: string;
time: number | null;