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
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:
1 parent
00cf194b5c
commit
0f421b4e44
15 files changed
+6264
-943
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+2407
-382
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user