@morlay/ui-conversation-manager 0.1.4 → 0.1.6
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/README.md +4 -0
- package/dist/client.cjs +480 -17245
- package/dist/client.d.cts +7 -3114
- package/dist/client.d.mts +8 -3115
- package/dist/client.mjs +1211 -0
- package/dist/index.cjs +5 -0
- package/package.json +21 -17
- package/src/client/ConversationManagerPage.module.css +226 -0
- package/src/client/ConversationManagerPage.tsx +455 -293
- package/src/client/index.ts +0 -2
- package/src/client/locales.ts +10 -0
- package/src/css-modules.d.ts +6 -0
- package/dist/index.d.cts +0 -3
- package/src/client/ConversationManagerPage.styles.ts +0 -256
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
// 「对话管理」页面:已归档会话的搜索、取消归档、导出、删除,以及导入为新会话与孤儿数据 GC。
|
|
2
2
|
// 数据面:会话行读**我们自己的**列表路由(注入面 listRows,含归档;搜索 / 子代理过滤 / 分页都在后端),
|
|
3
3
|
// 动作面只读注入面。
|
|
4
|
+
// 形态:页面框架与上游一级页面同款(960px 居中列 + 标题行 + 过滤 chip 行 + 搜索行,见
|
|
5
|
+
// `ConversationManagerPage.module.css`);切换与过滤都用那套 chip(`role="group"` + `aria-pressed`),
|
|
6
|
+
// 读取 / 空语料 / 读列表失败三种状态整屏居中。
|
|
4
7
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
5
8
|
import {
|
|
6
9
|
Button,
|
|
7
|
-
|
|
10
|
+
IconCloseOutlineRegular,
|
|
8
11
|
IconSearchOutlineRegular,
|
|
9
12
|
Input,
|
|
10
13
|
Modal,
|
|
14
|
+
Row,
|
|
15
|
+
Spinner,
|
|
16
|
+
Stack,
|
|
11
17
|
Tag,
|
|
18
|
+
Text,
|
|
12
19
|
relativeTime,
|
|
13
|
-
} from "@
|
|
14
|
-
import { styling } from "@morlay/dsh-client-ui-primitives/client";
|
|
20
|
+
} from "@morlay/dsh-client-ui-primitives";
|
|
15
21
|
import type { InjectFace, PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots";
|
|
16
22
|
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
17
23
|
import {
|
|
@@ -23,8 +29,8 @@ import {
|
|
|
23
29
|
type UsageRangeKey,
|
|
24
30
|
type UsageTotals,
|
|
25
31
|
} from "./controller.ts";
|
|
32
|
+
import frameCss from "./ConversationManagerPage.module.css";
|
|
26
33
|
import { formatCount, formatPercent, formatTokens } from "./format.ts";
|
|
27
|
-
import { styles } from "./ConversationManagerPage.styles.ts";
|
|
28
34
|
|
|
29
35
|
// 一页的行数(会话列表)。
|
|
30
36
|
const PAGE_SIZE = 20;
|
|
@@ -69,6 +75,69 @@ function failureText(error: unknown, t: Translate): string {
|
|
|
69
75
|
return t("failure.other", { reason: error instanceof Error ? error.message : String(error) });
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
// 过滤 chip:上游一级页面那套(28px 胶囊,选中那档带填充 + 主标签色)。语义是**筛选**:`role="group"` 的组里
|
|
79
|
+
// 每个 chip 用 `aria-pressed` 说选中,不是 tab 条。
|
|
80
|
+
function FilterChip({
|
|
81
|
+
active,
|
|
82
|
+
label,
|
|
83
|
+
tab,
|
|
84
|
+
onSelect,
|
|
85
|
+
}: {
|
|
86
|
+
active: boolean;
|
|
87
|
+
label: string;
|
|
88
|
+
// `data-tab` 标注(沟通与 e2e 按它定位那一个 chip)。
|
|
89
|
+
tab?: string;
|
|
90
|
+
onSelect: () => void;
|
|
91
|
+
}): ReactNode {
|
|
92
|
+
return (
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
className={active ? `${frameCss.filterTab} ${frameCss.filterTabActive}` : frameCss.filterTab}
|
|
96
|
+
aria-pressed={active}
|
|
97
|
+
{...(tab === undefined ? {} : { "data-tab": tab })}
|
|
98
|
+
onClick={onSelect}
|
|
99
|
+
>
|
|
100
|
+
{label}
|
|
101
|
+
</button>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 整屏居中的状态态:列表还没就绪 / 一份会话都没有 / 读列表失败。这三种时候页面没有别的内容可看,状态占满
|
|
106
|
+
// 整个面板(`.status` 住 `ConversationManagerPage.module.css`),因此不画标题行、过滤行与搜索行。
|
|
107
|
+
function PageStatus({
|
|
108
|
+
t,
|
|
109
|
+
status,
|
|
110
|
+
text,
|
|
111
|
+
spinner = false,
|
|
112
|
+
failure = false,
|
|
113
|
+
}: {
|
|
114
|
+
t: Translate;
|
|
115
|
+
status: "loading" | "empty" | "failure";
|
|
116
|
+
text: string;
|
|
117
|
+
spinner?: boolean;
|
|
118
|
+
failure?: boolean;
|
|
119
|
+
}): ReactNode {
|
|
120
|
+
return (
|
|
121
|
+
<section className={frameCss.page} aria-label={t("title")}>
|
|
122
|
+
<div className={frameCss.listPane}>
|
|
123
|
+
<div className={frameCss.status} data-status={status}>
|
|
124
|
+
<div className={frameCss.statusBody}>
|
|
125
|
+
{spinner ? <Spinner label={t("loading")} /> : null}
|
|
126
|
+
<p
|
|
127
|
+
className={
|
|
128
|
+
failure ? `${frameCss.statusText} ${frameCss.statusFailure}` : frameCss.statusText
|
|
129
|
+
}
|
|
130
|
+
{...(failure ? { role: "alert" } : {})}
|
|
131
|
+
>
|
|
132
|
+
{text}
|
|
133
|
+
</p>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</section>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
72
141
|
export function ConversationManagerPage({
|
|
73
142
|
t,
|
|
74
143
|
listRows,
|
|
@@ -91,6 +160,7 @@ export function ConversationManagerPage({
|
|
|
91
160
|
const [view, setView] = useState<PageView>("sessions");
|
|
92
161
|
const [usageTab, setUsageTab] = useState<UsageTab>("overview");
|
|
93
162
|
const [usageRange, setUsageRange] = useState<UsageRange>("day");
|
|
163
|
+
const [usageOnlySubagents, setUsageOnlySubagents] = useState(false);
|
|
94
164
|
const [usage, setUsage] = useState<SessionUsageReport | null>(null);
|
|
95
165
|
const [usageLoading, setUsageLoading] = useState(false);
|
|
96
166
|
const [usageError, setUsageError] = useState<string | null>(null);
|
|
@@ -208,7 +278,11 @@ export function ConversationManagerPage({
|
|
|
208
278
|
};
|
|
209
279
|
|
|
210
280
|
if (rowsPage === null) {
|
|
211
|
-
return
|
|
281
|
+
return rowsFailure === null ? (
|
|
282
|
+
<PageStatus t={t} status="loading" text={t("loading")} spinner />
|
|
283
|
+
) : (
|
|
284
|
+
<PageStatus t={t} status="failure" text={rowsFailure} failure />
|
|
285
|
+
);
|
|
212
286
|
}
|
|
213
287
|
|
|
214
288
|
const now = Date.now();
|
|
@@ -220,257 +294,298 @@ export function ConversationManagerPage({
|
|
|
220
294
|
const matched = rows;
|
|
221
295
|
const confirmed = confirming;
|
|
222
296
|
|
|
297
|
+
// 一份会话都没有(也没在搜索):标题行与搜索行都没有可做的事,整个面板就让给这个状态。
|
|
298
|
+
if (total === 0 && settledQuery === "") {
|
|
299
|
+
return <PageStatus t={t} status="empty" text={t("empty")} />;
|
|
300
|
+
}
|
|
301
|
+
|
|
223
302
|
return (
|
|
224
|
-
<
|
|
225
|
-
<div {
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
data-tab="sessions"
|
|
232
|
-
aria-selected={view === "sessions"}
|
|
233
|
-
className={styling.className(styles.tab, view === "sessions" && styles.tabActive)}
|
|
234
|
-
onClick={() => {
|
|
235
|
-
setView("sessions");
|
|
236
|
-
}}
|
|
237
|
-
>
|
|
238
|
-
{t("view.sessions")}
|
|
239
|
-
</button>
|
|
240
|
-
<button
|
|
241
|
-
type="button"
|
|
242
|
-
role="tab"
|
|
243
|
-
data-tab="usage"
|
|
244
|
-
aria-selected={view === "usage"}
|
|
245
|
-
className={styling.className(styles.tab, view === "usage" && styles.tabActive)}
|
|
246
|
-
onClick={openUsage}
|
|
247
|
-
>
|
|
248
|
-
{t("view.usage")}
|
|
249
|
-
</button>
|
|
250
|
-
</div>
|
|
251
|
-
<div {...styling.props(styles.headerActions)}>
|
|
252
|
-
<Button
|
|
253
|
-
variant="outline"
|
|
254
|
-
size="sm"
|
|
255
|
-
data-action="import"
|
|
256
|
-
disabled={importing}
|
|
257
|
-
aria-busy={importing}
|
|
258
|
-
aria-label={importing ? t("importing") : t("import")}
|
|
259
|
-
onClick={() => {
|
|
260
|
-
fileRef.current?.click();
|
|
261
|
-
}}
|
|
262
|
-
>
|
|
263
|
-
{importing ? t("importing") : t("import")}
|
|
264
|
-
</Button>
|
|
265
|
-
<Button
|
|
266
|
-
variant="outline"
|
|
267
|
-
size="sm"
|
|
268
|
-
data-action="gc"
|
|
269
|
-
disabled={gcPhase !== "idle"}
|
|
270
|
-
aria-label={t("gc.button")}
|
|
271
|
-
onClick={() => {
|
|
272
|
-
setFailure(null);
|
|
273
|
-
setNotice(null);
|
|
274
|
-
setGcPhase("confirm");
|
|
275
|
-
}}
|
|
276
|
-
>
|
|
277
|
-
{t("gc.button")}
|
|
278
|
-
</Button>
|
|
279
|
-
</div>
|
|
280
|
-
<input
|
|
281
|
-
ref={fileRef}
|
|
282
|
-
type="file"
|
|
283
|
-
accept=".zip,application/zip"
|
|
284
|
-
hidden
|
|
285
|
-
onChange={(event) => {
|
|
286
|
-
const file = event.currentTarget.files?.[0];
|
|
287
|
-
event.currentTarget.value = "";
|
|
288
|
-
if (file === undefined) return;
|
|
289
|
-
setImporting(true);
|
|
290
|
-
setFailure(null);
|
|
291
|
-
setNotice(null);
|
|
292
|
-
void importZip(file)
|
|
293
|
-
.then(
|
|
294
|
-
() => {
|
|
295
|
-
setNotice(t("imported"));
|
|
296
|
-
},
|
|
297
|
-
(error: unknown) => {
|
|
298
|
-
setFailure(failureText(error, t));
|
|
299
|
-
},
|
|
300
|
-
)
|
|
301
|
-
.finally(() => {
|
|
302
|
-
setImporting(false);
|
|
303
|
-
});
|
|
304
|
-
}}
|
|
305
|
-
/>
|
|
306
|
-
</div>
|
|
307
|
-
{view === "usage" ? (
|
|
308
|
-
<div {...styling.props(styles.scroll)} data-scroll="page">
|
|
309
|
-
<UsageView
|
|
310
|
-
report={usage}
|
|
311
|
-
loading={usageLoading}
|
|
312
|
-
error={usageError}
|
|
313
|
-
tab={usageTab}
|
|
314
|
-
onTab={setUsageTab}
|
|
315
|
-
range={usageRange}
|
|
316
|
-
onRange={requestUsage}
|
|
317
|
-
t={t}
|
|
318
|
-
/>
|
|
319
|
-
</div>
|
|
320
|
-
) : (
|
|
321
|
-
<>
|
|
322
|
-
<div {...styling.props(styles.filters)}>
|
|
323
|
-
<Input
|
|
324
|
-
className={styling.className(styles.search)}
|
|
325
|
-
data-filter="search"
|
|
326
|
-
type="search"
|
|
327
|
-
icon={<IconSearchOutlineRegular />}
|
|
328
|
-
value={query}
|
|
329
|
-
placeholder={t("search")}
|
|
330
|
-
aria-label={t("search")}
|
|
331
|
-
onChange={(event) => {
|
|
332
|
-
setQuery(event.currentTarget.value);
|
|
333
|
-
setPage(1);
|
|
334
|
-
}}
|
|
335
|
-
/>
|
|
336
|
-
<span data-filter="subagents">
|
|
337
|
-
<Checkbox
|
|
338
|
-
checked={showSubagents}
|
|
339
|
-
label={t("showSubagents")}
|
|
340
|
-
onChange={(next) => {
|
|
341
|
-
setShowSubagents(next);
|
|
342
|
-
setPage(1);
|
|
343
|
-
}}
|
|
344
|
-
/>
|
|
345
|
-
</span>
|
|
346
|
-
</div>
|
|
347
|
-
<div {...styling.props(styles.scroll)} data-scroll="page">
|
|
348
|
-
{notice === null ? null : (
|
|
349
|
-
<p {...styling.props(styles.status)} data-notice="result">
|
|
350
|
-
{notice}
|
|
351
|
-
</p>
|
|
352
|
-
)}
|
|
353
|
-
{failure === null ? null : (
|
|
354
|
-
<p {...styling.props(styles.failure)} data-failure="result" role="alert">
|
|
355
|
-
{failure}
|
|
356
|
-
</p>
|
|
357
|
-
)}
|
|
358
|
-
{total === 0 ? (
|
|
359
|
-
<p {...styling.props(styles.status)} data-status="empty">
|
|
360
|
-
{t(settledQuery === "" ? "empty" : "emptySearch")}
|
|
361
|
-
</p>
|
|
362
|
-
) : null}
|
|
363
|
-
{visible.length > 0 ? (
|
|
364
|
-
<ul {...styling.props(styles.list)}>
|
|
365
|
-
{visible.map((row) => (
|
|
366
|
-
<li
|
|
367
|
-
key={row.id}
|
|
368
|
-
{...styling.props(styles.row)}
|
|
369
|
-
data-session-id={String(row.id)}
|
|
370
|
-
data-archived={row.archived ? "true" : "false"}
|
|
371
|
-
data-subagent={row.subagent ? "true" : "false"}
|
|
372
|
-
>
|
|
373
|
-
<span {...styling.props(styles.identity)}>
|
|
374
|
-
<span {...styling.props(styles.titleLine)}>
|
|
375
|
-
<span {...styling.props(styles.rowTitle)}>{row.title}</span>
|
|
376
|
-
{row.archived ? <Tag tone="neutral">{t("archived")}</Tag> : null}
|
|
377
|
-
{row.subagent ? <Tag tone="quiet">{t("subagent")}</Tag> : null}
|
|
378
|
-
</span>
|
|
379
|
-
<span {...styling.props(styles.meta)}>
|
|
380
|
-
{[row.workspace, timeLabel(row.updatedAt, now, t)].join(" · ")}
|
|
381
|
-
</span>
|
|
382
|
-
</span>
|
|
383
|
-
<span {...styling.props(styles.actions)}>
|
|
384
|
-
{row.archived ? (
|
|
385
|
-
<Button
|
|
386
|
-
variant="outline"
|
|
387
|
-
size="sm"
|
|
388
|
-
data-action="unarchive"
|
|
389
|
-
aria-label={t("unarchiveNamed", { title: row.title })}
|
|
390
|
-
onClick={() => {
|
|
391
|
-
run(unarchive(row.id));
|
|
392
|
-
}}
|
|
393
|
-
>
|
|
394
|
-
{t("unarchive")}
|
|
395
|
-
</Button>
|
|
396
|
-
) : (
|
|
397
|
-
<Button
|
|
398
|
-
variant="outline"
|
|
399
|
-
size="sm"
|
|
400
|
-
data-action="archive"
|
|
401
|
-
aria-label={t("archiveNamed", { title: row.title })}
|
|
402
|
-
onClick={() => {
|
|
403
|
-
run(archive(row.id));
|
|
404
|
-
}}
|
|
405
|
-
>
|
|
406
|
-
{t("archive")}
|
|
407
|
-
</Button>
|
|
408
|
-
)}
|
|
409
|
-
<Button
|
|
410
|
-
variant="outline"
|
|
411
|
-
size="sm"
|
|
412
|
-
data-action="export"
|
|
413
|
-
aria-label={t("exportNamed", { title: row.title })}
|
|
414
|
-
onClick={() => {
|
|
415
|
-
run(exportZip(row.id));
|
|
416
|
-
}}
|
|
417
|
-
>
|
|
418
|
-
{t("export")}
|
|
419
|
-
</Button>
|
|
420
|
-
<Button
|
|
421
|
-
variant="outline"
|
|
422
|
-
size="sm"
|
|
423
|
-
disabled={!row.archived}
|
|
424
|
-
data-action="remove"
|
|
425
|
-
aria-label={t("removeNamed", { title: row.title })}
|
|
426
|
-
onClick={() => {
|
|
427
|
-
setFailure(null);
|
|
428
|
-
setNotice(null);
|
|
429
|
-
setConfirming(row);
|
|
430
|
-
}}
|
|
431
|
-
>
|
|
432
|
-
{t("remove")}
|
|
433
|
-
</Button>
|
|
434
|
-
</span>
|
|
435
|
-
</li>
|
|
436
|
-
))}
|
|
437
|
-
</ul>
|
|
438
|
-
) : null}
|
|
439
|
-
{matched.length > 0 ? (
|
|
440
|
-
<div
|
|
441
|
-
{...styling.props(styles.pagination)}
|
|
442
|
-
data-pagination=""
|
|
443
|
-
data-page-current={currentPage}
|
|
444
|
-
data-page-total={pageCount}
|
|
445
|
-
>
|
|
303
|
+
<section className={frameCss.page} data-view={view} aria-label={t("title")}>
|
|
304
|
+
<div className={frameCss.listPane}>
|
|
305
|
+
<div className={frameCss.pageScroll} data-scroll="page">
|
|
306
|
+
<div className={frameCss.pageContent}>
|
|
307
|
+
<div className={frameCss.pageHeading}>
|
|
308
|
+
<h1>{t("title")}</h1>
|
|
309
|
+
<div className={frameCss.headingActions}>
|
|
446
310
|
<Button
|
|
447
|
-
variant="
|
|
311
|
+
variant="outline"
|
|
448
312
|
size="sm"
|
|
449
|
-
|
|
313
|
+
data-action="import"
|
|
314
|
+
disabled={importing}
|
|
315
|
+
aria-busy={importing}
|
|
316
|
+
aria-label={importing ? t("importing") : t("import")}
|
|
450
317
|
onClick={() => {
|
|
451
|
-
|
|
318
|
+
fileRef.current?.click();
|
|
452
319
|
}}
|
|
453
320
|
>
|
|
454
|
-
{t("
|
|
321
|
+
{importing ? t("importing") : t("import")}
|
|
455
322
|
</Button>
|
|
456
|
-
<span {...styling.props(styles.paginationLabel)}>
|
|
457
|
-
{t("page.label", { page: currentPage, total: pageCount })}
|
|
458
|
-
</span>
|
|
459
323
|
<Button
|
|
460
|
-
variant="
|
|
324
|
+
variant="outline"
|
|
461
325
|
size="sm"
|
|
462
|
-
|
|
326
|
+
data-action="gc"
|
|
327
|
+
disabled={gcPhase !== "idle"}
|
|
328
|
+
aria-label={t("gc.button")}
|
|
463
329
|
onClick={() => {
|
|
464
|
-
|
|
330
|
+
setFailure(null);
|
|
331
|
+
setNotice(null);
|
|
332
|
+
setGcPhase("confirm");
|
|
465
333
|
}}
|
|
466
334
|
>
|
|
467
|
-
{t("
|
|
335
|
+
{t("gc.button")}
|
|
468
336
|
</Button>
|
|
469
337
|
</div>
|
|
470
|
-
|
|
338
|
+
</div>
|
|
339
|
+
<input
|
|
340
|
+
ref={fileRef}
|
|
341
|
+
type="file"
|
|
342
|
+
accept=".zip,application/zip"
|
|
343
|
+
hidden
|
|
344
|
+
onChange={(event) => {
|
|
345
|
+
const file = event.currentTarget.files?.[0];
|
|
346
|
+
event.currentTarget.value = "";
|
|
347
|
+
if (file === undefined) return;
|
|
348
|
+
setImporting(true);
|
|
349
|
+
setFailure(null);
|
|
350
|
+
setNotice(null);
|
|
351
|
+
void importZip(file)
|
|
352
|
+
.then(
|
|
353
|
+
() => {
|
|
354
|
+
setNotice(t("imported"));
|
|
355
|
+
},
|
|
356
|
+
(error: unknown) => {
|
|
357
|
+
setFailure(failureText(error, t));
|
|
358
|
+
},
|
|
359
|
+
)
|
|
360
|
+
.finally(() => {
|
|
361
|
+
setImporting(false);
|
|
362
|
+
});
|
|
363
|
+
}}
|
|
364
|
+
/>
|
|
365
|
+
<div className={frameCss.filters}>
|
|
366
|
+
<div
|
|
367
|
+
className={frameCss.filterTabs}
|
|
368
|
+
role="group"
|
|
369
|
+
aria-label={t("filter.view")}
|
|
370
|
+
data-filter="view"
|
|
371
|
+
>
|
|
372
|
+
<FilterChip
|
|
373
|
+
active={view === "sessions"}
|
|
374
|
+
label={t("view.sessions")}
|
|
375
|
+
tab="sessions"
|
|
376
|
+
onSelect={() => {
|
|
377
|
+
setView("sessions");
|
|
378
|
+
}}
|
|
379
|
+
/>
|
|
380
|
+
<FilterChip
|
|
381
|
+
active={view === "usage"}
|
|
382
|
+
label={t("view.usage")}
|
|
383
|
+
tab="usage"
|
|
384
|
+
onSelect={openUsage}
|
|
385
|
+
/>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
{view === "usage" ? null : (
|
|
389
|
+
<div className={frameCss.filters}>
|
|
390
|
+
<div
|
|
391
|
+
className={frameCss.filterTabs}
|
|
392
|
+
role="group"
|
|
393
|
+
aria-label={t("filter.subagents")}
|
|
394
|
+
data-filter="subagents"
|
|
395
|
+
>
|
|
396
|
+
<FilterChip
|
|
397
|
+
active={showSubagents}
|
|
398
|
+
label={t("showSubagents")}
|
|
399
|
+
onSelect={() => {
|
|
400
|
+
setShowSubagents(!showSubagents);
|
|
401
|
+
setPage(1);
|
|
402
|
+
}}
|
|
403
|
+
/>
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
)}
|
|
407
|
+
{view === "usage" ? null : (
|
|
408
|
+
<div className={frameCss.searchField}>
|
|
409
|
+
<Input
|
|
410
|
+
data-filter="search"
|
|
411
|
+
type="search"
|
|
412
|
+
icon={<IconSearchOutlineRegular />}
|
|
413
|
+
value={query}
|
|
414
|
+
placeholder={t("search")}
|
|
415
|
+
aria-label={t("search")}
|
|
416
|
+
onChange={(event) => {
|
|
417
|
+
setQuery(event.currentTarget.value);
|
|
418
|
+
setPage(1);
|
|
419
|
+
}}
|
|
420
|
+
/>
|
|
421
|
+
{query === "" ? null : (
|
|
422
|
+
<Button
|
|
423
|
+
variant="ghost"
|
|
424
|
+
size="sm"
|
|
425
|
+
className={frameCss.searchClear}
|
|
426
|
+
aria-label={t("clearSearch")}
|
|
427
|
+
onClick={() => {
|
|
428
|
+
setQuery("");
|
|
429
|
+
setPage(1);
|
|
430
|
+
}}
|
|
431
|
+
>
|
|
432
|
+
<IconCloseOutlineRegular />
|
|
433
|
+
</Button>
|
|
434
|
+
)}
|
|
435
|
+
</div>
|
|
436
|
+
)}
|
|
437
|
+
{view === "usage" ? (
|
|
438
|
+
<UsageView
|
|
439
|
+
report={usage}
|
|
440
|
+
loading={usageLoading}
|
|
441
|
+
error={usageError}
|
|
442
|
+
tab={usageTab}
|
|
443
|
+
onTab={setUsageTab}
|
|
444
|
+
range={usageRange}
|
|
445
|
+
onRange={requestUsage}
|
|
446
|
+
onlySubagents={usageOnlySubagents}
|
|
447
|
+
onOnlySubagents={setUsageOnlySubagents}
|
|
448
|
+
t={t}
|
|
449
|
+
/>
|
|
450
|
+
) : (
|
|
451
|
+
<div className={frameCss.list}>
|
|
452
|
+
{notice === null ? null : (
|
|
453
|
+
<Text data-notice="result" as="p" size="md" tone="tertiary">
|
|
454
|
+
{notice}
|
|
455
|
+
</Text>
|
|
456
|
+
)}
|
|
457
|
+
{failure === null ? null : (
|
|
458
|
+
<Text data-failure="result" role="alert" as="p" size="md" tone="danger">
|
|
459
|
+
{failure}
|
|
460
|
+
</Text>
|
|
461
|
+
)}
|
|
462
|
+
{total === 0 ? (
|
|
463
|
+
<div className={frameCss.listStatus} data-status="empty-search">
|
|
464
|
+
<p className={frameCss.statusText}>{t("emptySearch")}</p>
|
|
465
|
+
</div>
|
|
466
|
+
) : null}
|
|
467
|
+
{visible.length > 0 ? (
|
|
468
|
+
<Stack as="ul" gap={4} plain>
|
|
469
|
+
{visible.map((row) => (
|
|
470
|
+
<Row
|
|
471
|
+
as="li"
|
|
472
|
+
key={row.id}
|
|
473
|
+
gap={16}
|
|
474
|
+
justify="between"
|
|
475
|
+
boxed
|
|
476
|
+
pad="row"
|
|
477
|
+
fixed
|
|
478
|
+
data-session-id={String(row.id)}
|
|
479
|
+
data-archived={row.archived ? "true" : "false"}
|
|
480
|
+
data-subagent={row.subagent ? "true" : "false"}
|
|
481
|
+
>
|
|
482
|
+
<Stack gap={2} grow>
|
|
483
|
+
<Row gap={8} grow>
|
|
484
|
+
<Text size="md" truncate>
|
|
485
|
+
{row.title}
|
|
486
|
+
</Text>
|
|
487
|
+
{row.archived ? <Tag tone="neutral">{t("archived")}</Tag> : null}
|
|
488
|
+
{row.subagent ? <Tag tone="quiet">{t("subagent")}</Tag> : null}
|
|
489
|
+
</Row>
|
|
490
|
+
<Text size="sm" tone="tertiary">
|
|
491
|
+
{[row.workspace, timeLabel(row.updatedAt, now, t)].join(" · ")}
|
|
492
|
+
</Text>
|
|
493
|
+
</Stack>
|
|
494
|
+
<Row gap={8} fixed>
|
|
495
|
+
{row.archived ? (
|
|
496
|
+
<Button
|
|
497
|
+
variant="outline"
|
|
498
|
+
size="sm"
|
|
499
|
+
data-action="unarchive"
|
|
500
|
+
aria-label={t("unarchiveNamed", { title: row.title })}
|
|
501
|
+
onClick={() => {
|
|
502
|
+
run(unarchive(row.id));
|
|
503
|
+
}}
|
|
504
|
+
>
|
|
505
|
+
{t("unarchive")}
|
|
506
|
+
</Button>
|
|
507
|
+
) : (
|
|
508
|
+
<Button
|
|
509
|
+
variant="outline"
|
|
510
|
+
size="sm"
|
|
511
|
+
data-action="archive"
|
|
512
|
+
aria-label={t("archiveNamed", { title: row.title })}
|
|
513
|
+
onClick={() => {
|
|
514
|
+
run(archive(row.id));
|
|
515
|
+
}}
|
|
516
|
+
>
|
|
517
|
+
{t("archive")}
|
|
518
|
+
</Button>
|
|
519
|
+
)}
|
|
520
|
+
<Button
|
|
521
|
+
variant="outline"
|
|
522
|
+
size="sm"
|
|
523
|
+
data-action="export"
|
|
524
|
+
aria-label={t("exportNamed", { title: row.title })}
|
|
525
|
+
onClick={() => {
|
|
526
|
+
run(exportZip(row.id));
|
|
527
|
+
}}
|
|
528
|
+
>
|
|
529
|
+
{t("export")}
|
|
530
|
+
</Button>
|
|
531
|
+
<Button
|
|
532
|
+
variant="outline"
|
|
533
|
+
size="sm"
|
|
534
|
+
disabled={!row.archived}
|
|
535
|
+
data-action="remove"
|
|
536
|
+
aria-label={t("removeNamed", { title: row.title })}
|
|
537
|
+
onClick={() => {
|
|
538
|
+
setFailure(null);
|
|
539
|
+
setNotice(null);
|
|
540
|
+
setConfirming(row);
|
|
541
|
+
}}
|
|
542
|
+
>
|
|
543
|
+
{t("remove")}
|
|
544
|
+
</Button>
|
|
545
|
+
</Row>
|
|
546
|
+
</Row>
|
|
547
|
+
))}
|
|
548
|
+
</Stack>
|
|
549
|
+
) : null}
|
|
550
|
+
{matched.length > 0 ? (
|
|
551
|
+
<Row
|
|
552
|
+
data-pagination=""
|
|
553
|
+
data-page-current={currentPage}
|
|
554
|
+
data-page-total={pageCount}
|
|
555
|
+
gap={8}
|
|
556
|
+
justify="end"
|
|
557
|
+
fixed
|
|
558
|
+
>
|
|
559
|
+
<Button
|
|
560
|
+
variant="ghost"
|
|
561
|
+
size="sm"
|
|
562
|
+
disabled={currentPage <= 1}
|
|
563
|
+
onClick={() => {
|
|
564
|
+
setPage(currentPage - 1);
|
|
565
|
+
}}
|
|
566
|
+
>
|
|
567
|
+
{t("page.previous")}
|
|
568
|
+
</Button>
|
|
569
|
+
<Text size="sm" tone="tertiary" tabular>
|
|
570
|
+
{t("page.label", { page: currentPage, total: pageCount })}
|
|
571
|
+
</Text>
|
|
572
|
+
<Button
|
|
573
|
+
variant="ghost"
|
|
574
|
+
size="sm"
|
|
575
|
+
disabled={currentPage >= pageCount}
|
|
576
|
+
onClick={() => {
|
|
577
|
+
setPage(currentPage + 1);
|
|
578
|
+
}}
|
|
579
|
+
>
|
|
580
|
+
{t("page.next")}
|
|
581
|
+
</Button>
|
|
582
|
+
</Row>
|
|
583
|
+
) : null}
|
|
584
|
+
</div>
|
|
585
|
+
)}
|
|
471
586
|
</div>
|
|
472
|
-
|
|
473
|
-
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
474
589
|
<Modal
|
|
475
590
|
open={confirmed !== null}
|
|
476
591
|
onClose={() => {
|
|
@@ -529,12 +644,14 @@ export function ConversationManagerPage({
|
|
|
529
644
|
/>
|
|
530
645
|
{/* 运行期不给关闭手段:没有关闭按钮,mask 与 Escape 都只走空 onClose。 */}
|
|
531
646
|
<Modal open={gcPhase === "running"} onClose={() => {}} headless title={t("gc.title")}>
|
|
532
|
-
<
|
|
533
|
-
<
|
|
534
|
-
<p
|
|
535
|
-
|
|
647
|
+
<Stack align="center" gap={14} pad="blocking">
|
|
648
|
+
<Spinner label={t("gc.running")} />
|
|
649
|
+
<Text as="p" size="md">
|
|
650
|
+
{t("gc.running")}
|
|
651
|
+
</Text>
|
|
652
|
+
</Stack>
|
|
536
653
|
</Modal>
|
|
537
|
-
</
|
|
654
|
+
</section>
|
|
538
655
|
);
|
|
539
656
|
}
|
|
540
657
|
|
|
@@ -647,6 +764,8 @@ interface UsageListRow {
|
|
|
647
764
|
key: string;
|
|
648
765
|
label: string;
|
|
649
766
|
totals: UsageTotals;
|
|
767
|
+
// 子代理派生会话的行(按会话维度才有此维度;其余维度不带标记)。
|
|
768
|
+
subagent?: boolean;
|
|
650
769
|
}
|
|
651
770
|
|
|
652
771
|
function emptyTotals(): UsageTotals {
|
|
@@ -663,7 +782,7 @@ function emptyTotals(): UsageTotals {
|
|
|
663
782
|
};
|
|
664
783
|
}
|
|
665
784
|
|
|
666
|
-
//
|
|
785
|
+
// 把桶按一个键折叠成行并按用量降序(按模型维度用它:人类的桶与子代理的桶折进同一行)。
|
|
667
786
|
function foldBuckets(
|
|
668
787
|
buckets: readonly UsageBucket[],
|
|
669
788
|
keyOf: (bucket: UsageBucket) => string,
|
|
@@ -684,43 +803,60 @@ function foldBuckets(
|
|
|
684
803
|
);
|
|
685
804
|
}
|
|
686
805
|
|
|
687
|
-
// 一行用量:label
|
|
806
|
+
// 一行用量:label 在上(子代理行另带标记),下面是横向排布的单项。
|
|
688
807
|
function UsageRow({
|
|
689
808
|
rowKey,
|
|
690
809
|
label,
|
|
691
810
|
totals,
|
|
692
811
|
t,
|
|
812
|
+
subagent,
|
|
693
813
|
withActivity = true,
|
|
694
814
|
}: {
|
|
695
815
|
rowKey: string;
|
|
696
816
|
label: string;
|
|
697
817
|
totals: UsageTotals;
|
|
698
818
|
t: Translate;
|
|
819
|
+
subagent?: boolean;
|
|
699
820
|
withActivity?: boolean;
|
|
700
821
|
}): ReactNode {
|
|
701
822
|
return (
|
|
702
|
-
<
|
|
703
|
-
|
|
704
|
-
|
|
823
|
+
<Stack
|
|
824
|
+
as="li"
|
|
825
|
+
gap={6}
|
|
826
|
+
boxed
|
|
827
|
+
pad="row"
|
|
828
|
+
fixed
|
|
829
|
+
data-usage-key={rowKey}
|
|
830
|
+
{...(subagent === undefined ? {} : { "data-subagent": subagent ? "true" : "false" })}
|
|
831
|
+
>
|
|
832
|
+
<Row gap={8} grow>
|
|
833
|
+
<Text size="sm" tone="tertiary" truncate>
|
|
834
|
+
{label}
|
|
835
|
+
</Text>
|
|
836
|
+
{subagent === true ? <Tag tone="quiet">{t("subagent")}</Tag> : null}
|
|
837
|
+
</Row>
|
|
838
|
+
<Row gap="wide" wrap>
|
|
705
839
|
{usageMetrics(totals, t, { withActivity }).map((metric) => (
|
|
706
|
-
<
|
|
840
|
+
<Stack
|
|
707
841
|
key={metric.key}
|
|
708
|
-
{...styling.props(styles.usageMetric)}
|
|
709
842
|
data-usage-cell={metric.key}
|
|
710
843
|
data-usage-value={metric.value}
|
|
844
|
+
gap={2}
|
|
711
845
|
>
|
|
712
|
-
<
|
|
713
|
-
|
|
846
|
+
<Text size="sm" tone="tertiary">
|
|
847
|
+
{metric.label}
|
|
848
|
+
</Text>
|
|
849
|
+
<Text size="lg" weight="medium" tabular>
|
|
714
850
|
{metric.kind === "percent"
|
|
715
851
|
? formatPercent(metric.value)
|
|
716
852
|
: metric.kind === "count"
|
|
717
853
|
? formatCount(metric.value)
|
|
718
854
|
: formatTokens(metric.value)}
|
|
719
|
-
</
|
|
720
|
-
</
|
|
855
|
+
</Text>
|
|
856
|
+
</Stack>
|
|
721
857
|
))}
|
|
722
|
-
</
|
|
723
|
-
</
|
|
858
|
+
</Row>
|
|
859
|
+
</Stack>
|
|
724
860
|
);
|
|
725
861
|
}
|
|
726
862
|
|
|
@@ -735,13 +871,13 @@ function UsageList({
|
|
|
735
871
|
}): ReactNode {
|
|
736
872
|
if (rows.length === 0) {
|
|
737
873
|
return (
|
|
738
|
-
<
|
|
874
|
+
<Text data-usage-status="empty" as="p" size="md" tone="tertiary">
|
|
739
875
|
{t("usage.empty")}
|
|
740
|
-
</
|
|
876
|
+
</Text>
|
|
741
877
|
);
|
|
742
878
|
}
|
|
743
879
|
return (
|
|
744
|
-
<ul {
|
|
880
|
+
<Stack as="ul" gap={4} plain>
|
|
745
881
|
{rows.map((row) => (
|
|
746
882
|
<UsageRow
|
|
747
883
|
key={row.key}
|
|
@@ -749,20 +885,21 @@ function UsageList({
|
|
|
749
885
|
label={row.label}
|
|
750
886
|
totals={row.totals}
|
|
751
887
|
t={t}
|
|
888
|
+
{...(row.subagent === undefined ? {} : { subagent: row.subagent })}
|
|
752
889
|
withActivity={withActivity}
|
|
753
890
|
/>
|
|
754
891
|
))}
|
|
755
|
-
</
|
|
892
|
+
</Stack>
|
|
756
893
|
);
|
|
757
894
|
}
|
|
758
895
|
|
|
759
896
|
// 总览:全部与「其中子代理」两行,与列表行同形。
|
|
760
897
|
function UsageOverview({ report, t }: { report: SessionUsageReport; t: Translate }): ReactNode {
|
|
761
898
|
return (
|
|
762
|
-
<ul {
|
|
899
|
+
<Stack as="ul" gap={4} plain>
|
|
763
900
|
<UsageRow rowKey="all" label={t("usage.all")} totals={report.totals} t={t} />
|
|
764
901
|
<UsageRow rowKey="subagent" label={t("usage.subagentOnly")} totals={report.subagent} t={t} />
|
|
765
|
-
</
|
|
902
|
+
</Stack>
|
|
766
903
|
);
|
|
767
904
|
}
|
|
768
905
|
|
|
@@ -775,6 +912,8 @@ function UsageView({
|
|
|
775
912
|
onTab,
|
|
776
913
|
range,
|
|
777
914
|
onRange,
|
|
915
|
+
onlySubagents,
|
|
916
|
+
onOnlySubagents,
|
|
778
917
|
t,
|
|
779
918
|
}: {
|
|
780
919
|
report: SessionUsageReport | null;
|
|
@@ -784,6 +923,8 @@ function UsageView({
|
|
|
784
923
|
onTab: (tab: UsageTab) => void;
|
|
785
924
|
range: UsageRange;
|
|
786
925
|
onRange: (range: UsageRange) => void;
|
|
926
|
+
onlySubagents: boolean;
|
|
927
|
+
onOnlySubagents: (next: boolean) => void;
|
|
787
928
|
t: Translate;
|
|
788
929
|
}): ReactNode {
|
|
789
930
|
const items: UsageTab[] = ["overview", "models", "sessions"];
|
|
@@ -802,64 +943,85 @@ function UsageView({
|
|
|
802
943
|
`${bucket.provider ?? t("usage.unknownModel")} / ${bucket.model ?? t("usage.unknownModel")}`,
|
|
803
944
|
)
|
|
804
945
|
: report.sessions
|
|
946
|
+
// 子代理会话单个用量小,混排时会被行数上限挤掉:给一个只看它们的开关(过滤在本页,数据已全量在手)。
|
|
947
|
+
.filter((row) => !onlySubagents || row.subagent)
|
|
805
948
|
.map((row) => ({
|
|
806
949
|
key: row.sessionId,
|
|
807
950
|
label: row.title ?? row.sessionId,
|
|
808
951
|
totals: row,
|
|
952
|
+
subagent: row.subagent,
|
|
809
953
|
}))
|
|
810
954
|
.sort((left, right) => sortWeight(right.totals) - sortWeight(left.totals))
|
|
811
955
|
.slice(0, USAGE_SESSION_ROWS);
|
|
812
956
|
return (
|
|
813
|
-
<
|
|
814
|
-
<div
|
|
957
|
+
<Stack data-usage-view={tab} data-usage-range={range} gap={12} fixed>
|
|
958
|
+
<div
|
|
959
|
+
className={frameCss.filterTabs}
|
|
960
|
+
role="group"
|
|
961
|
+
aria-label={t("usage.range")}
|
|
962
|
+
data-filter="usage-range"
|
|
963
|
+
>
|
|
815
964
|
{USAGE_RANGES.map((option) => (
|
|
816
|
-
<
|
|
965
|
+
<FilterChip
|
|
817
966
|
key={option}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
className={styling.className(styles.tab, range === option && styles.tabActive)}
|
|
823
|
-
onClick={() => {
|
|
967
|
+
active={option === range}
|
|
968
|
+
label={rangeLabel(option, t)}
|
|
969
|
+
tab={option}
|
|
970
|
+
onSelect={() => {
|
|
824
971
|
onRange(option);
|
|
825
972
|
}}
|
|
826
|
-
|
|
827
|
-
{rangeLabel(option, t)}
|
|
828
|
-
</button>
|
|
973
|
+
/>
|
|
829
974
|
))}
|
|
830
975
|
</div>
|
|
831
|
-
<div
|
|
976
|
+
<div
|
|
977
|
+
className={frameCss.filterTabs}
|
|
978
|
+
role="group"
|
|
979
|
+
aria-label={t("usage.dimension")}
|
|
980
|
+
data-filter="usage-tabs"
|
|
981
|
+
>
|
|
832
982
|
{items.map((item) => (
|
|
833
|
-
<
|
|
983
|
+
<FilterChip
|
|
834
984
|
key={item}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
className={styling.className(styles.tab, tab === item && styles.tabActive)}
|
|
840
|
-
onClick={() => {
|
|
985
|
+
active={item === tab}
|
|
986
|
+
label={labels[item]}
|
|
987
|
+
tab={item}
|
|
988
|
+
onSelect={() => {
|
|
841
989
|
onTab(item);
|
|
842
990
|
}}
|
|
843
|
-
|
|
844
|
-
{labels[item]}
|
|
845
|
-
</button>
|
|
991
|
+
/>
|
|
846
992
|
))}
|
|
847
993
|
</div>
|
|
994
|
+
{tab === "sessions" ? (
|
|
995
|
+
<div
|
|
996
|
+
className={frameCss.filterTabs}
|
|
997
|
+
role="group"
|
|
998
|
+
aria-label={t("filter.subagents")}
|
|
999
|
+
data-filter="usage-subagents"
|
|
1000
|
+
>
|
|
1001
|
+
<FilterChip
|
|
1002
|
+
active={onlySubagents}
|
|
1003
|
+
label={t("usage.onlySubagents")}
|
|
1004
|
+
onSelect={() => {
|
|
1005
|
+
onOnlySubagents(!onlySubagents);
|
|
1006
|
+
}}
|
|
1007
|
+
/>
|
|
1008
|
+
</div>
|
|
1009
|
+
) : null}
|
|
848
1010
|
{loading ? (
|
|
849
|
-
<
|
|
1011
|
+
<Text data-usage-status="loading" as="p" size="md" tone="tertiary">
|
|
850
1012
|
{t("usage.loading")}
|
|
851
|
-
</
|
|
1013
|
+
</Text>
|
|
852
1014
|
) : null}
|
|
853
1015
|
{error === null ? null : (
|
|
854
|
-
<
|
|
1016
|
+
<Text data-usage-status="error" role="alert" as="p" size="md" tone="danger">
|
|
855
1017
|
{error}
|
|
856
|
-
</
|
|
1018
|
+
</Text>
|
|
857
1019
|
)}
|
|
858
1020
|
{report === null ? null : tab === "overview" ? (
|
|
859
1021
|
<UsageOverview report={report} t={t} />
|
|
860
1022
|
) : (
|
|
861
1023
|
<UsageList rows={rows} t={t} withActivity={tab === "sessions"} />
|
|
862
1024
|
)}
|
|
863
|
-
</
|
|
1025
|
+
</Stack>
|
|
864
1026
|
);
|
|
865
1027
|
}
|