@morlay/ui-conversation-manager 0.0.2-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/client.cjs +1257 -0
- package/dist/client.d.cts +3125 -0
- package/dist/client.d.mts +3125 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +5 -0
- package/package.json +70 -0
- package/src/client/ConversationManagerIcon.tsx +9 -0
- package/src/client/ConversationManagerPage.styles.ts +247 -0
- package/src/client/ConversationManagerPage.tsx +801 -0
- package/src/client/controller.ts +216 -0
- package/src/client/format.ts +22 -0
- package/src/client/index.ts +76 -0
- package/src/client/locales.ts +152 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
// 「对话管理」页面:已归档会话的搜索、取消归档、导出、删除,以及导入为新会话与孤儿数据 GC。
|
|
2
|
+
// 数据面只读框架标准座位(useSessions / useWorkspaces),动作面只读注入面。
|
|
3
|
+
import { useMemo, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
Checkbox,
|
|
7
|
+
IconSearchOutline16,
|
|
8
|
+
Input,
|
|
9
|
+
Modal,
|
|
10
|
+
Tag,
|
|
11
|
+
relativeTime,
|
|
12
|
+
} from "@deepseek-ai/dsh-client-ui-primitives";
|
|
13
|
+
import { styling } from "@morlay/dsh-client-ui-primitives/client";
|
|
14
|
+
import type { InjectFace, PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots";
|
|
15
|
+
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
16
|
+
import {
|
|
17
|
+
ConversationManagerRequestError,
|
|
18
|
+
type ConversationManagerFace,
|
|
19
|
+
type SessionUsageReport,
|
|
20
|
+
type UsageBucket,
|
|
21
|
+
type UsageRangeKey,
|
|
22
|
+
type UsageTotals,
|
|
23
|
+
} from "./controller.ts";
|
|
24
|
+
import { formatPercent, formatTokens } from "./format.ts";
|
|
25
|
+
import { styles } from "./ConversationManagerPage.styles.ts";
|
|
26
|
+
|
|
27
|
+
/** 一页的行数(会话列表)。 */
|
|
28
|
+
const PAGE_SIZE = 20;
|
|
29
|
+
|
|
30
|
+
/** 统计视图按会话列出时的行数上限。 */
|
|
31
|
+
const USAGE_SESSION_ROWS = 20;
|
|
32
|
+
|
|
33
|
+
/** 页面 props:main 座位的运行时份额 + 本包字典 + 注入的动作。 */
|
|
34
|
+
export type ConversationManagerPageProps = PropsRuntime<"main"> &
|
|
35
|
+
PropsLocale<"conversationManager"> &
|
|
36
|
+
InjectFace<ConversationManagerFace>;
|
|
37
|
+
|
|
38
|
+
type Translate = ConversationManagerPageProps["t"];
|
|
39
|
+
|
|
40
|
+
/** GC 的三段状态:确认 → 运行(阻塞界面)→ 收尾。 */
|
|
41
|
+
type GcPhase = "idle" | "confirm" | "running";
|
|
42
|
+
|
|
43
|
+
interface ConversationRow {
|
|
44
|
+
id: SessionId;
|
|
45
|
+
title: string;
|
|
46
|
+
/** 所属工作区标题;不在任何工作区里的会话用未分组文案。 */
|
|
47
|
+
workspace: string;
|
|
48
|
+
/** 只有已归档的行允许取消归档与删除(host 侧同样守卫)。 */
|
|
49
|
+
archived: boolean;
|
|
50
|
+
/** 子代理派生会话:默认不显示(既不可删也不可取消归档)。 */
|
|
51
|
+
subagent: boolean;
|
|
52
|
+
updatedAt: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 行上显示的紧凑相对时间。 */
|
|
56
|
+
function timeLabel(updatedAt: number, now: number, t: Translate): string {
|
|
57
|
+
const { unit, n } = relativeTime(updatedAt, now);
|
|
58
|
+
return unit === "now" ? t("time.now") : t(`time.${unit}`, { n });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 标题或工作区名命中归一化后的查询。 */
|
|
62
|
+
function matches(row: ConversationRow, normalizedQuery: string): boolean {
|
|
63
|
+
return (
|
|
64
|
+
normalizedQuery.length === 0 ||
|
|
65
|
+
row.title.toLowerCase().includes(normalizedQuery) ||
|
|
66
|
+
row.workspace.toLowerCase().includes(normalizedQuery)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** host 错误码 → 可读文案;没有码时保留原文。 */
|
|
71
|
+
function failureText(error: unknown, t: Translate): string {
|
|
72
|
+
const code = error instanceof ConversationManagerRequestError ? error.code : undefined;
|
|
73
|
+
if (code === "SESSION_NOT_ARCHIVED") return t("failure.notArchived");
|
|
74
|
+
if (code === "SESSION_LIVE") return t("failure.live");
|
|
75
|
+
if (code === "SESSION_NOT_FOUND") return t("failure.missing");
|
|
76
|
+
return t("failure.other", { reason: error instanceof Error ? error.message : String(error) });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function ConversationManagerPage({
|
|
80
|
+
t,
|
|
81
|
+
useSessions,
|
|
82
|
+
useWorkspaces,
|
|
83
|
+
archive,
|
|
84
|
+
unarchive,
|
|
85
|
+
remove,
|
|
86
|
+
exportZip,
|
|
87
|
+
importZip,
|
|
88
|
+
collectGarbage,
|
|
89
|
+
loadUsage,
|
|
90
|
+
}: ConversationManagerPageProps): ReactNode {
|
|
91
|
+
const sessions = useSessions((state) => state);
|
|
92
|
+
const workspaces = useWorkspaces((state) => state);
|
|
93
|
+
const [query, setQuery] = useState("");
|
|
94
|
+
const [page, setPage] = useState(1);
|
|
95
|
+
const [showSubagents, setShowSubagents] = useState(false);
|
|
96
|
+
const [view, setView] = useState<PageView>("sessions");
|
|
97
|
+
const [usageTab, setUsageTab] = useState<UsageTab>("overview");
|
|
98
|
+
const [usageRange, setUsageRange] = useState<UsageRange>("day");
|
|
99
|
+
const [usage, setUsage] = useState<SessionUsageReport | null>(null);
|
|
100
|
+
const [usageLoading, setUsageLoading] = useState(false);
|
|
101
|
+
const [usageError, setUsageError] = useState<string | null>(null);
|
|
102
|
+
const [confirming, setConfirming] = useState<ConversationRow | null>(null);
|
|
103
|
+
const [gcPhase, setGcPhase] = useState<GcPhase>("idle");
|
|
104
|
+
const [importing, setImporting] = useState(false);
|
|
105
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
106
|
+
const [failure, setFailure] = useState<string | null>(null);
|
|
107
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
108
|
+
const ungrouped = t("ungrouped");
|
|
109
|
+
|
|
110
|
+
// 全量会话:会话目录(ids)∪ 归档集,按最近活动在前;没有加载到 summary 的成员不产生行。
|
|
111
|
+
const rows = useMemo<ConversationRow[]>(() => {
|
|
112
|
+
const owners = new Map<string, string>();
|
|
113
|
+
for (const workspace of workspaces.items) {
|
|
114
|
+
for (const id of workspace.sessionIds) owners.set(id, workspace.title);
|
|
115
|
+
}
|
|
116
|
+
const archivedIds = new Set<SessionId>(workspaces.archivedSessionIds);
|
|
117
|
+
const ids = new Set<SessionId>([...sessions.ids, ...workspaces.archivedSessionIds]);
|
|
118
|
+
return [...ids]
|
|
119
|
+
.flatMap((id) => {
|
|
120
|
+
const summary = sessions.byId[id];
|
|
121
|
+
if (summary === undefined) return [];
|
|
122
|
+
return [
|
|
123
|
+
{
|
|
124
|
+
id,
|
|
125
|
+
title: summary.displayTitle,
|
|
126
|
+
workspace: owners.get(id) ?? ungrouped,
|
|
127
|
+
archived: archivedIds.has(id),
|
|
128
|
+
subagent: summary.origin === "subagent",
|
|
129
|
+
updatedAt: summary.updatedAt,
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
})
|
|
133
|
+
.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
134
|
+
}, [workspaces, sessions.ids, sessions.byId, ungrouped]);
|
|
135
|
+
|
|
136
|
+
// 一个动作的收尾:成败都收掉弹窗,失败把原因落到页面上的提示行。
|
|
137
|
+
const run = (action: Promise<unknown>, settle?: () => void): void => {
|
|
138
|
+
setFailure(null);
|
|
139
|
+
setNotice(null);
|
|
140
|
+
void action.then(
|
|
141
|
+
() => {
|
|
142
|
+
settle?.();
|
|
143
|
+
},
|
|
144
|
+
(error: unknown) => {
|
|
145
|
+
settle?.();
|
|
146
|
+
setFailure(failureText(error, t));
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const startGc = (): void => {
|
|
152
|
+
setGcPhase("running");
|
|
153
|
+
setFailure(null);
|
|
154
|
+
setNotice(null);
|
|
155
|
+
void collectGarbage().then(
|
|
156
|
+
(result) => {
|
|
157
|
+
setGcPhase("idle");
|
|
158
|
+
setNotice(t("gc.done", { sessions: result.orphanSessions, events: result.orphanEvents }));
|
|
159
|
+
},
|
|
160
|
+
(error: unknown) => {
|
|
161
|
+
setGcPhase("idle");
|
|
162
|
+
setFailure(failureText(error, t));
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// 统计是按时间范围在 host 侧聚合的:进入统计视图拉一次,换范围再拉一次;维度切换本地折叠。
|
|
168
|
+
const requestUsage = (range: UsageRange): void => {
|
|
169
|
+
setUsageRange(range);
|
|
170
|
+
setUsageLoading(true);
|
|
171
|
+
setUsageError(null);
|
|
172
|
+
void loadUsage(range)
|
|
173
|
+
.then(
|
|
174
|
+
(report) => {
|
|
175
|
+
setUsage(report);
|
|
176
|
+
},
|
|
177
|
+
(error: unknown) => {
|
|
178
|
+
setUsageError(failureText(error, t));
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
.finally(() => {
|
|
182
|
+
setUsageLoading(false);
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const openUsage = (): void => {
|
|
187
|
+
setView("usage");
|
|
188
|
+
if (usage === null && !usageLoading) requestUsage(usageRange);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
if (sessions.phase !== "ready") {
|
|
192
|
+
return <p {...styling.props(styles.status)}>{t("loading")}</p>;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
// 子代理派生会话默认不列:它们不可删也不可取消归档,只会淹没真实对话。
|
|
197
|
+
const listed = showSubagents ? rows : rows.filter((row) => !row.subagent);
|
|
198
|
+
const matched = listed.filter((row) => matches(row, query.trim().toLowerCase()));
|
|
199
|
+
const pageCount = Math.max(1, Math.ceil(matched.length / PAGE_SIZE));
|
|
200
|
+
const currentPage = Math.min(page, pageCount);
|
|
201
|
+
const visible = matched.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
|
202
|
+
const confirmed = confirming;
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div {...styling.props(styles.page)} data-view={view}>
|
|
206
|
+
<div {...styling.props(styles.header)}>
|
|
207
|
+
<h1 {...styling.props(styles.title)}>{t("title")}</h1>
|
|
208
|
+
<div {...styling.props(styles.tabs)} role="tablist">
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
role="tab"
|
|
212
|
+
data-tab="sessions"
|
|
213
|
+
aria-selected={view === "sessions"}
|
|
214
|
+
className={styling.className(styles.tab, view === "sessions" && styles.tabActive)}
|
|
215
|
+
onClick={() => {
|
|
216
|
+
setView("sessions");
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
{t("view.sessions")}
|
|
220
|
+
</button>
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
role="tab"
|
|
224
|
+
data-tab="usage"
|
|
225
|
+
aria-selected={view === "usage"}
|
|
226
|
+
className={styling.className(styles.tab, view === "usage" && styles.tabActive)}
|
|
227
|
+
onClick={openUsage}
|
|
228
|
+
>
|
|
229
|
+
{t("view.usage")}
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
<div {...styling.props(styles.headerActions)}>
|
|
233
|
+
<Button
|
|
234
|
+
variant="outline"
|
|
235
|
+
size="sm"
|
|
236
|
+
data-action="import"
|
|
237
|
+
disabled={importing}
|
|
238
|
+
aria-busy={importing}
|
|
239
|
+
aria-label={importing ? t("importing") : t("import")}
|
|
240
|
+
onClick={() => {
|
|
241
|
+
fileRef.current?.click();
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
{importing ? t("importing") : t("import")}
|
|
245
|
+
</Button>
|
|
246
|
+
<Button
|
|
247
|
+
variant="outline"
|
|
248
|
+
size="sm"
|
|
249
|
+
data-action="gc"
|
|
250
|
+
disabled={gcPhase !== "idle"}
|
|
251
|
+
aria-label={t("gc.button")}
|
|
252
|
+
onClick={() => {
|
|
253
|
+
setFailure(null);
|
|
254
|
+
setNotice(null);
|
|
255
|
+
setGcPhase("confirm");
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
{t("gc.button")}
|
|
259
|
+
</Button>
|
|
260
|
+
</div>
|
|
261
|
+
<input
|
|
262
|
+
ref={fileRef}
|
|
263
|
+
type="file"
|
|
264
|
+
accept=".zip,application/zip"
|
|
265
|
+
hidden
|
|
266
|
+
onChange={(event) => {
|
|
267
|
+
const file = event.currentTarget.files?.[0];
|
|
268
|
+
event.currentTarget.value = "";
|
|
269
|
+
if (file === undefined) return;
|
|
270
|
+
setImporting(true);
|
|
271
|
+
setFailure(null);
|
|
272
|
+
setNotice(null);
|
|
273
|
+
void importZip(file)
|
|
274
|
+
.then(
|
|
275
|
+
() => {
|
|
276
|
+
setNotice(t("imported"));
|
|
277
|
+
},
|
|
278
|
+
(error: unknown) => {
|
|
279
|
+
setFailure(failureText(error, t));
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
.finally(() => {
|
|
283
|
+
setImporting(false);
|
|
284
|
+
});
|
|
285
|
+
}}
|
|
286
|
+
/>
|
|
287
|
+
</div>
|
|
288
|
+
{view === "usage" ? (
|
|
289
|
+
<UsageView
|
|
290
|
+
report={usage}
|
|
291
|
+
loading={usageLoading}
|
|
292
|
+
error={usageError}
|
|
293
|
+
tab={usageTab}
|
|
294
|
+
onTab={setUsageTab}
|
|
295
|
+
range={usageRange}
|
|
296
|
+
onRange={requestUsage}
|
|
297
|
+
t={t}
|
|
298
|
+
/>
|
|
299
|
+
) : (
|
|
300
|
+
<>
|
|
301
|
+
<div {...styling.props(styles.filters)}>
|
|
302
|
+
<Input
|
|
303
|
+
className={styling.className(styles.search)}
|
|
304
|
+
data-filter="search"
|
|
305
|
+
type="search"
|
|
306
|
+
icon={<IconSearchOutline16 />}
|
|
307
|
+
value={query}
|
|
308
|
+
placeholder={t("search")}
|
|
309
|
+
aria-label={t("search")}
|
|
310
|
+
onChange={(event) => {
|
|
311
|
+
setQuery(event.currentTarget.value);
|
|
312
|
+
setPage(1);
|
|
313
|
+
}}
|
|
314
|
+
/>
|
|
315
|
+
<span data-filter="subagents">
|
|
316
|
+
<Checkbox
|
|
317
|
+
checked={showSubagents}
|
|
318
|
+
label={t("showSubagents")}
|
|
319
|
+
onChange={(next) => {
|
|
320
|
+
setShowSubagents(next);
|
|
321
|
+
setPage(1);
|
|
322
|
+
}}
|
|
323
|
+
/>
|
|
324
|
+
</span>
|
|
325
|
+
</div>
|
|
326
|
+
{notice === null ? null : (
|
|
327
|
+
<p {...styling.props(styles.status)} data-notice="result">
|
|
328
|
+
{notice}
|
|
329
|
+
</p>
|
|
330
|
+
)}
|
|
331
|
+
{failure === null ? null : (
|
|
332
|
+
<p {...styling.props(styles.failure)} data-failure="result" role="alert">
|
|
333
|
+
{failure}
|
|
334
|
+
</p>
|
|
335
|
+
)}
|
|
336
|
+
{listed.length === 0 ? (
|
|
337
|
+
<p {...styling.props(styles.status)} data-status="empty">
|
|
338
|
+
{t("empty")}
|
|
339
|
+
</p>
|
|
340
|
+
) : null}
|
|
341
|
+
{listed.length > 0 && matched.length === 0 ? (
|
|
342
|
+
<p {...styling.props(styles.status)} data-status="empty-search">
|
|
343
|
+
{t("emptySearch")}
|
|
344
|
+
</p>
|
|
345
|
+
) : null}
|
|
346
|
+
{visible.length > 0 ? (
|
|
347
|
+
<ul {...styling.props(styles.list)}>
|
|
348
|
+
{visible.map((row) => (
|
|
349
|
+
<li
|
|
350
|
+
key={row.id}
|
|
351
|
+
{...styling.props(styles.row)}
|
|
352
|
+
data-session-id={String(row.id)}
|
|
353
|
+
data-archived={row.archived ? "true" : "false"}
|
|
354
|
+
data-subagent={row.subagent ? "true" : "false"}
|
|
355
|
+
>
|
|
356
|
+
<span {...styling.props(styles.identity)}>
|
|
357
|
+
<span {...styling.props(styles.titleLine)}>
|
|
358
|
+
<span {...styling.props(styles.rowTitle)}>{row.title}</span>
|
|
359
|
+
{row.archived ? <Tag tone="neutral">{t("archived")}</Tag> : null}
|
|
360
|
+
{row.subagent ? <Tag tone="quiet">{t("subagent")}</Tag> : null}
|
|
361
|
+
</span>
|
|
362
|
+
<span {...styling.props(styles.meta)}>
|
|
363
|
+
{[row.workspace, timeLabel(row.updatedAt, now, t)].join(" · ")}
|
|
364
|
+
</span>
|
|
365
|
+
</span>
|
|
366
|
+
<span {...styling.props(styles.actions)}>
|
|
367
|
+
{row.archived ? (
|
|
368
|
+
<Button
|
|
369
|
+
variant="outline"
|
|
370
|
+
size="sm"
|
|
371
|
+
data-action="unarchive"
|
|
372
|
+
aria-label={t("unarchiveNamed", { title: row.title })}
|
|
373
|
+
onClick={() => {
|
|
374
|
+
run(unarchive(row.id));
|
|
375
|
+
}}
|
|
376
|
+
>
|
|
377
|
+
{t("unarchive")}
|
|
378
|
+
</Button>
|
|
379
|
+
) : (
|
|
380
|
+
<Button
|
|
381
|
+
variant="outline"
|
|
382
|
+
size="sm"
|
|
383
|
+
data-action="archive"
|
|
384
|
+
aria-label={t("archiveNamed", { title: row.title })}
|
|
385
|
+
onClick={() => {
|
|
386
|
+
run(archive(row.id));
|
|
387
|
+
}}
|
|
388
|
+
>
|
|
389
|
+
{t("archive")}
|
|
390
|
+
</Button>
|
|
391
|
+
)}
|
|
392
|
+
<Button
|
|
393
|
+
variant="outline"
|
|
394
|
+
size="sm"
|
|
395
|
+
data-action="export"
|
|
396
|
+
aria-label={t("exportNamed", { title: row.title })}
|
|
397
|
+
onClick={() => {
|
|
398
|
+
run(exportZip(row.id));
|
|
399
|
+
}}
|
|
400
|
+
>
|
|
401
|
+
{t("export")}
|
|
402
|
+
</Button>
|
|
403
|
+
<Button
|
|
404
|
+
variant="outline"
|
|
405
|
+
size="sm"
|
|
406
|
+
disabled={!row.archived}
|
|
407
|
+
data-action="remove"
|
|
408
|
+
aria-label={t("removeNamed", { title: row.title })}
|
|
409
|
+
onClick={() => {
|
|
410
|
+
setFailure(null);
|
|
411
|
+
setNotice(null);
|
|
412
|
+
setConfirming(row);
|
|
413
|
+
}}
|
|
414
|
+
>
|
|
415
|
+
{t("remove")}
|
|
416
|
+
</Button>
|
|
417
|
+
</span>
|
|
418
|
+
</li>
|
|
419
|
+
))}
|
|
420
|
+
</ul>
|
|
421
|
+
) : null}
|
|
422
|
+
{matched.length > 0 ? (
|
|
423
|
+
<div
|
|
424
|
+
{...styling.props(styles.pagination)}
|
|
425
|
+
data-pagination=""
|
|
426
|
+
data-page-current={currentPage}
|
|
427
|
+
data-page-total={pageCount}
|
|
428
|
+
>
|
|
429
|
+
<Button
|
|
430
|
+
variant="ghost"
|
|
431
|
+
size="sm"
|
|
432
|
+
disabled={currentPage <= 1}
|
|
433
|
+
onClick={() => {
|
|
434
|
+
setPage(currentPage - 1);
|
|
435
|
+
}}
|
|
436
|
+
>
|
|
437
|
+
{t("page.previous")}
|
|
438
|
+
</Button>
|
|
439
|
+
<span {...styling.props(styles.paginationLabel)}>
|
|
440
|
+
{t("page.label", { page: currentPage, total: pageCount })}
|
|
441
|
+
</span>
|
|
442
|
+
<Button
|
|
443
|
+
variant="ghost"
|
|
444
|
+
size="sm"
|
|
445
|
+
disabled={currentPage >= pageCount}
|
|
446
|
+
onClick={() => {
|
|
447
|
+
setPage(currentPage + 1);
|
|
448
|
+
}}
|
|
449
|
+
>
|
|
450
|
+
{t("page.next")}
|
|
451
|
+
</Button>
|
|
452
|
+
</div>
|
|
453
|
+
) : null}
|
|
454
|
+
</>
|
|
455
|
+
)}
|
|
456
|
+
<Modal
|
|
457
|
+
open={confirmed !== null}
|
|
458
|
+
onClose={() => {
|
|
459
|
+
setConfirming(null);
|
|
460
|
+
}}
|
|
461
|
+
title={t("confirmTitle")}
|
|
462
|
+
closeLabel={t("close")}
|
|
463
|
+
description={t("confirmDescription")}
|
|
464
|
+
footer={
|
|
465
|
+
<>
|
|
466
|
+
<Button
|
|
467
|
+
variant="ghost"
|
|
468
|
+
onClick={() => {
|
|
469
|
+
setConfirming(null);
|
|
470
|
+
}}
|
|
471
|
+
>
|
|
472
|
+
{t("confirmCancel")}
|
|
473
|
+
</Button>
|
|
474
|
+
<Button
|
|
475
|
+
variant="primary"
|
|
476
|
+
onClick={() => {
|
|
477
|
+
if (confirmed === null) return;
|
|
478
|
+
run(remove(confirmed.id), () => {
|
|
479
|
+
setConfirming(null);
|
|
480
|
+
});
|
|
481
|
+
}}
|
|
482
|
+
>
|
|
483
|
+
{t("confirmAccept")}
|
|
484
|
+
</Button>
|
|
485
|
+
</>
|
|
486
|
+
}
|
|
487
|
+
/>
|
|
488
|
+
<Modal
|
|
489
|
+
open={gcPhase === "confirm"}
|
|
490
|
+
onClose={() => {
|
|
491
|
+
setGcPhase("idle");
|
|
492
|
+
}}
|
|
493
|
+
title={t("gc.title")}
|
|
494
|
+
closeLabel={t("close")}
|
|
495
|
+
description={t("gc.description")}
|
|
496
|
+
footer={
|
|
497
|
+
<>
|
|
498
|
+
<Button
|
|
499
|
+
variant="ghost"
|
|
500
|
+
onClick={() => {
|
|
501
|
+
setGcPhase("idle");
|
|
502
|
+
}}
|
|
503
|
+
>
|
|
504
|
+
{t("gc.cancel")}
|
|
505
|
+
</Button>
|
|
506
|
+
<Button variant="primary" onClick={startGc}>
|
|
507
|
+
{t("gc.confirm")}
|
|
508
|
+
</Button>
|
|
509
|
+
</>
|
|
510
|
+
}
|
|
511
|
+
/>
|
|
512
|
+
{/* 运行期不给关闭手段:没有关闭按钮,mask 与 Escape 都只走空 onClose。 */}
|
|
513
|
+
<Modal open={gcPhase === "running"} onClose={() => {}} headless title={t("gc.title")}>
|
|
514
|
+
<div {...styling.props(styles.blocking)}>
|
|
515
|
+
<span {...styling.props(styles.spinner)} aria-hidden="true" />
|
|
516
|
+
<p {...styling.props(styles.blockingText)}>{t("gc.running")}</p>
|
|
517
|
+
</div>
|
|
518
|
+
</Modal>
|
|
519
|
+
</div>
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** 一层视图:会话列表 / 用量统计。 */
|
|
524
|
+
type PageView = "sessions" | "usage";
|
|
525
|
+
|
|
526
|
+
/** 统计视图的二层维度(时间范围取代了原来的「按天」)。 */
|
|
527
|
+
type UsageTab = "overview" | "models" | "sessions";
|
|
528
|
+
|
|
529
|
+
/** 时间范围选项:默认本日,其后是本周(周一起算)与最近 N 天,「全部」放在最后。 */
|
|
530
|
+
const USAGE_RANGES: readonly UsageRange[] = ["day", "week", "7d", "30d", "90d", "all"];
|
|
531
|
+
|
|
532
|
+
type UsageRange = UsageRangeKey;
|
|
533
|
+
|
|
534
|
+
/** 范围按钮的文案。 */
|
|
535
|
+
function rangeLabel(range: UsageRange, t: Translate): string {
|
|
536
|
+
switch (range) {
|
|
537
|
+
case "all":
|
|
538
|
+
return t("usage.range.all");
|
|
539
|
+
case "day":
|
|
540
|
+
return t("usage.range.day");
|
|
541
|
+
case "week":
|
|
542
|
+
return t("usage.range.week");
|
|
543
|
+
case "7d":
|
|
544
|
+
return t("usage.range.days", { n: 7 });
|
|
545
|
+
case "30d":
|
|
546
|
+
return t("usage.range.days", { n: 30 });
|
|
547
|
+
case "90d":
|
|
548
|
+
return t("usage.range.days", { n: 90 });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** 一个用量单项:标签在上、值在下;单项之间横向排布。 */
|
|
553
|
+
interface UsageMetric {
|
|
554
|
+
key: string;
|
|
555
|
+
label: string;
|
|
556
|
+
/** 原始值:token 数,或百分点(`percent` 项)。 */
|
|
557
|
+
value: number;
|
|
558
|
+
kind: "tokens" | "percent";
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** 缓存命中率(百分点):缓存输入占总输入(含缓存)的比例。 */
|
|
562
|
+
function cacheHitPercent(totals: UsageTotals): number {
|
|
563
|
+
const total = totals.inputTokens + totals.cacheReadTokens;
|
|
564
|
+
if (total <= 0) return 0;
|
|
565
|
+
return (totals.cacheReadTokens / total) * 100;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** 显示口径的单项:输入(含缓存输入)、缓存输入、缓存命中率、输出、推理、事件——没有合计项。 */
|
|
569
|
+
function usageMetrics(totals: UsageTotals, t: Translate): UsageMetric[] {
|
|
570
|
+
return [
|
|
571
|
+
{
|
|
572
|
+
key: "input",
|
|
573
|
+
label: t("usage.inputWithCache"),
|
|
574
|
+
value: totals.inputTokens + totals.cacheReadTokens,
|
|
575
|
+
kind: "tokens",
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
key: "cacheInput",
|
|
579
|
+
label: t("usage.cacheInput"),
|
|
580
|
+
value: totals.cacheReadTokens,
|
|
581
|
+
kind: "tokens",
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
key: "cacheRate",
|
|
585
|
+
label: t("usage.cacheRate"),
|
|
586
|
+
value: cacheHitPercent(totals),
|
|
587
|
+
kind: "percent",
|
|
588
|
+
},
|
|
589
|
+
{ key: "output", label: t("usage.output"), value: totals.outputTokens, kind: "tokens" },
|
|
590
|
+
{
|
|
591
|
+
key: "reasoning",
|
|
592
|
+
label: t("usage.reasoning"),
|
|
593
|
+
value: totals.reasoningTokens,
|
|
594
|
+
kind: "tokens",
|
|
595
|
+
},
|
|
596
|
+
{ key: "events", label: t("usage.events"), value: totals.events, kind: "tokens" },
|
|
597
|
+
];
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** 折叠行的排序口径(不显示):输入(含缓存)+ 输出。 */
|
|
601
|
+
function sortWeight(totals: UsageTotals): number {
|
|
602
|
+
return totals.inputTokens + totals.cacheReadTokens + totals.outputTokens;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
interface UsageListRow {
|
|
606
|
+
key: string;
|
|
607
|
+
label: string;
|
|
608
|
+
totals: UsageTotals;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function emptyTotals(): UsageTotals {
|
|
612
|
+
return {
|
|
613
|
+
events: 0,
|
|
614
|
+
inputTokens: 0,
|
|
615
|
+
outputTokens: 0,
|
|
616
|
+
cacheReadTokens: 0,
|
|
617
|
+
reasoningTokens: 0,
|
|
618
|
+
totalTokens: 0,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** 把桶按一个键折叠成行并按用量降序(按天 / 按模型都用它)。 */
|
|
623
|
+
function foldBuckets(
|
|
624
|
+
buckets: readonly UsageBucket[],
|
|
625
|
+
keyOf: (bucket: UsageBucket) => string,
|
|
626
|
+
): UsageListRow[] {
|
|
627
|
+
const folded = new Map<string, UsageListRow>();
|
|
628
|
+
for (const bucket of buckets) {
|
|
629
|
+
const key = keyOf(bucket);
|
|
630
|
+
const row = folded.get(key) ?? { key, label: key, totals: emptyTotals() };
|
|
631
|
+
row.totals.events += bucket.events;
|
|
632
|
+
row.totals.inputTokens += bucket.inputTokens;
|
|
633
|
+
row.totals.outputTokens += bucket.outputTokens;
|
|
634
|
+
row.totals.cacheReadTokens += bucket.cacheReadTokens;
|
|
635
|
+
row.totals.reasoningTokens += bucket.reasoningTokens;
|
|
636
|
+
row.totals.totalTokens += bucket.totalTokens;
|
|
637
|
+
folded.set(key, row);
|
|
638
|
+
}
|
|
639
|
+
return [...folded.values()].sort(
|
|
640
|
+
(left, right) => sortWeight(right.totals) - sortWeight(left.totals),
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** 一行用量:label 在上,下面是横向排布的单项。 */
|
|
645
|
+
function UsageRow({
|
|
646
|
+
rowKey,
|
|
647
|
+
label,
|
|
648
|
+
totals,
|
|
649
|
+
t,
|
|
650
|
+
}: {
|
|
651
|
+
rowKey: string;
|
|
652
|
+
label: string;
|
|
653
|
+
totals: UsageTotals;
|
|
654
|
+
t: Translate;
|
|
655
|
+
}): ReactNode {
|
|
656
|
+
return (
|
|
657
|
+
<li {...styling.props(styles.usageRow)} data-usage-key={rowKey}>
|
|
658
|
+
<span {...styling.props(styles.usageRowLabel)}>{label}</span>
|
|
659
|
+
<span {...styling.props(styles.usageMetrics)}>
|
|
660
|
+
{usageMetrics(totals, t).map((metric) => (
|
|
661
|
+
<span
|
|
662
|
+
key={metric.key}
|
|
663
|
+
{...styling.props(styles.usageMetric)}
|
|
664
|
+
data-usage-cell={metric.key}
|
|
665
|
+
data-usage-value={metric.value}
|
|
666
|
+
>
|
|
667
|
+
<span {...styling.props(styles.usageMetricLabel)}>{metric.label}</span>
|
|
668
|
+
<span {...styling.props(styles.usageMetricValue)}>
|
|
669
|
+
{metric.kind === "percent" ? formatPercent(metric.value) : formatTokens(metric.value)}
|
|
670
|
+
</span>
|
|
671
|
+
</span>
|
|
672
|
+
))}
|
|
673
|
+
</span>
|
|
674
|
+
</li>
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function UsageList({ rows, t }: { rows: readonly UsageListRow[]; t: Translate }): ReactNode {
|
|
679
|
+
if (rows.length === 0) {
|
|
680
|
+
return (
|
|
681
|
+
<p {...styling.props(styles.status)} data-usage-status="empty">
|
|
682
|
+
{t("usage.empty")}
|
|
683
|
+
</p>
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
return (
|
|
687
|
+
<ul {...styling.props(styles.usageList)}>
|
|
688
|
+
{rows.map((row) => (
|
|
689
|
+
<UsageRow key={row.key} rowKey={row.key} label={row.label} totals={row.totals} t={t} />
|
|
690
|
+
))}
|
|
691
|
+
</ul>
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** 总览:全部与「其中子代理」两行,与列表行同形。 */
|
|
696
|
+
function UsageOverview({ report, t }: { report: SessionUsageReport; t: Translate }): ReactNode {
|
|
697
|
+
return (
|
|
698
|
+
<ul {...styling.props(styles.usageList)}>
|
|
699
|
+
<UsageRow rowKey="all" label={t("usage.all")} totals={report.totals} t={t} />
|
|
700
|
+
<UsageRow rowKey="subagent" label={t("usage.subagentOnly")} totals={report.subagent} t={t} />
|
|
701
|
+
</ul>
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** 统计视图:时间范围过滤 + 二层维度切换(总览 / 按模型 / 按会话)。 */
|
|
706
|
+
function UsageView({
|
|
707
|
+
report,
|
|
708
|
+
loading,
|
|
709
|
+
error,
|
|
710
|
+
tab,
|
|
711
|
+
onTab,
|
|
712
|
+
range,
|
|
713
|
+
onRange,
|
|
714
|
+
t,
|
|
715
|
+
}: {
|
|
716
|
+
report: SessionUsageReport | null;
|
|
717
|
+
loading: boolean;
|
|
718
|
+
error: string | null;
|
|
719
|
+
tab: UsageTab;
|
|
720
|
+
onTab: (tab: UsageTab) => void;
|
|
721
|
+
range: UsageRange;
|
|
722
|
+
onRange: (range: UsageRange) => void;
|
|
723
|
+
t: Translate;
|
|
724
|
+
}): ReactNode {
|
|
725
|
+
const items: UsageTab[] = ["overview", "models", "sessions"];
|
|
726
|
+
const labels: Record<UsageTab, string> = {
|
|
727
|
+
overview: t("usage.overview"),
|
|
728
|
+
models: t("usage.models"),
|
|
729
|
+
sessions: t("usage.sessions"),
|
|
730
|
+
};
|
|
731
|
+
const rows: readonly UsageListRow[] =
|
|
732
|
+
report === null || tab === "overview"
|
|
733
|
+
? []
|
|
734
|
+
: tab === "models"
|
|
735
|
+
? foldBuckets(
|
|
736
|
+
report.buckets,
|
|
737
|
+
(bucket) =>
|
|
738
|
+
`${bucket.provider ?? t("usage.unknownModel")} / ${bucket.model ?? t("usage.unknownModel")}`,
|
|
739
|
+
)
|
|
740
|
+
: report.sessions
|
|
741
|
+
.map((row) => ({
|
|
742
|
+
key: row.sessionId,
|
|
743
|
+
label: row.title ?? row.sessionId,
|
|
744
|
+
totals: row,
|
|
745
|
+
}))
|
|
746
|
+
.sort((left, right) => sortWeight(right.totals) - sortWeight(left.totals))
|
|
747
|
+
.slice(0, USAGE_SESSION_ROWS);
|
|
748
|
+
return (
|
|
749
|
+
<div {...styling.props(styles.usage)} data-usage-view={tab} data-usage-range={range}>
|
|
750
|
+
<div {...styling.props(styles.tabs)} role="radiogroup" aria-label={t("usage.range")}>
|
|
751
|
+
{USAGE_RANGES.map((option) => (
|
|
752
|
+
<button
|
|
753
|
+
key={option}
|
|
754
|
+
type="button"
|
|
755
|
+
role="radio"
|
|
756
|
+
aria-checked={range === option}
|
|
757
|
+
data-range={option}
|
|
758
|
+
className={styling.className(styles.tab, range === option && styles.tabActive)}
|
|
759
|
+
onClick={() => {
|
|
760
|
+
onRange(option);
|
|
761
|
+
}}
|
|
762
|
+
>
|
|
763
|
+
{rangeLabel(option, t)}
|
|
764
|
+
</button>
|
|
765
|
+
))}
|
|
766
|
+
</div>
|
|
767
|
+
<div {...styling.props(styles.tabs)} role="tablist">
|
|
768
|
+
{items.map((item) => (
|
|
769
|
+
<button
|
|
770
|
+
key={item}
|
|
771
|
+
type="button"
|
|
772
|
+
role="tab"
|
|
773
|
+
data-usage-tab={item}
|
|
774
|
+
aria-selected={tab === item}
|
|
775
|
+
className={styling.className(styles.tab, tab === item && styles.tabActive)}
|
|
776
|
+
onClick={() => {
|
|
777
|
+
onTab(item);
|
|
778
|
+
}}
|
|
779
|
+
>
|
|
780
|
+
{labels[item]}
|
|
781
|
+
</button>
|
|
782
|
+
))}
|
|
783
|
+
</div>
|
|
784
|
+
{loading ? (
|
|
785
|
+
<p {...styling.props(styles.status)} data-usage-status="loading">
|
|
786
|
+
{t("usage.loading")}
|
|
787
|
+
</p>
|
|
788
|
+
) : null}
|
|
789
|
+
{error === null ? null : (
|
|
790
|
+
<p {...styling.props(styles.failure)} data-usage-status="error" role="alert">
|
|
791
|
+
{error}
|
|
792
|
+
</p>
|
|
793
|
+
)}
|
|
794
|
+
{report === null ? null : tab === "overview" ? (
|
|
795
|
+
<UsageOverview report={report} t={t} />
|
|
796
|
+
) : (
|
|
797
|
+
<UsageList rows={rows} t={t} />
|
|
798
|
+
)}
|
|
799
|
+
</div>
|
|
800
|
+
);
|
|
801
|
+
}
|