@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.
@@ -0,0 +1,216 @@
1
+ import type { SessionId } from "@deepseek-ai/dsh-session";
2
+
3
+ /** host 侧已存在的会话路由。 */
4
+ export const SESSION_DELETE_PATH = "/api/session.delete";
5
+ export const SESSION_IMPORT_PATH = "/api/session.import";
6
+ export const SESSION_EXPORT_PATH = "/api/session.export";
7
+ export const SESSION_GC_PATH = "/api/session.gc";
8
+ export const SESSION_USAGE_PATH = "/api/session.usage";
9
+
10
+ /** 时间范围的语义键(与 session-rdb `./usage` 的 `UsageRangeKey` 镜像)。 */
11
+ export type UsageRangeKey = "all" | "day" | "week" | "7d" | "30d" | "90d";
12
+
13
+ /** 一段用量合计(与 session-rdb `./usage` 的回报结构镜像)。 */
14
+ export interface UsageTotals {
15
+ events: number;
16
+ inputTokens: number;
17
+ outputTokens: number;
18
+ cacheReadTokens: number;
19
+ reasoningTokens: number;
20
+ totalTokens: number;
21
+ }
22
+
23
+ /** 一天 × 一个模型 × 是否子代理 的用量桶。 */
24
+ export interface UsageBucket extends UsageTotals {
25
+ day: string;
26
+ provider: string | null;
27
+ model: string | null;
28
+ subagent: boolean;
29
+ }
30
+
31
+ /** 一条会话的用量行。 */
32
+ export interface UsageSessionRow extends UsageTotals {
33
+ sessionId: string;
34
+ title: string | null;
35
+ subagent: boolean;
36
+ archived: boolean;
37
+ }
38
+
39
+ /** 一次统计请求的回报:总览 + subagent 拆分 + 桶 + 会话行。 */
40
+ export interface SessionUsageReport {
41
+ totals: UsageTotals;
42
+ subagent: UsageTotals;
43
+ /** 只被人类会话引用的部分。 */
44
+ human: UsageTotals;
45
+ buckets: UsageBucket[];
46
+ sessions: UsageSessionRow[];
47
+ }
48
+
49
+ /** 页面之外的服务面:归档状态与列表刷新都归它们的既有 owner。 */
50
+ export interface ConversationManagerPorts {
51
+ archiveSession(sessionId: SessionId): Promise<void>;
52
+ unarchiveSession(sessionId: SessionId): Promise<void>;
53
+ refresh(): Promise<void>;
54
+ }
55
+
56
+ /** GC 一次执行的回报。 */
57
+ export interface ConversationManagerGcResult {
58
+ orphanSessions: number;
59
+ orphanEvents: number;
60
+ stoppedAgents: number;
61
+ }
62
+
63
+ /** 页面从注入面拿到的动作(属性语法:页面解构后直接调用,不绑 this)。 */
64
+ export interface ConversationManagerFace {
65
+ archive: (sessionId: SessionId) => Promise<void>;
66
+ unarchive: (sessionId: SessionId) => Promise<void>;
67
+ remove: (sessionId: SessionId) => Promise<void>;
68
+ exportZip: (sessionId: SessionId) => Promise<void>;
69
+ importZip: (file: File) => Promise<SessionId>;
70
+ collectGarbage: () => Promise<ConversationManagerGcResult>;
71
+ loadUsage: (range: UsageRangeKey) => Promise<SessionUsageReport>;
72
+ }
73
+
74
+ /** 带 host 错误码的请求失败:页面据此选本地化文案。 */
75
+ export class ConversationManagerRequestError extends Error {
76
+ constructor(
77
+ message: string,
78
+ readonly code: string | undefined,
79
+ ) {
80
+ super(message);
81
+ this.name = "ConversationManagerRequestError";
82
+ }
83
+ }
84
+
85
+ async function postJson(path: string, body: unknown): Promise<Record<string, unknown>> {
86
+ const response = await fetch(path, {
87
+ method: "POST",
88
+ headers: { accept: "application/json", "content-type": "application/json" },
89
+ body: JSON.stringify(body),
90
+ });
91
+ const value = (await response.json().catch(() => ({}))) as {
92
+ error?: unknown;
93
+ code?: unknown;
94
+ };
95
+ if (!response.ok) {
96
+ throw new ConversationManagerRequestError(
97
+ typeof value.error === "string" ? value.error : `请求失败:HTTP ${response.status}`,
98
+ typeof value.code === "string" ? value.code : undefined,
99
+ );
100
+ }
101
+ return value;
102
+ }
103
+
104
+ async function zipBase64(file: File): Promise<string> {
105
+ const dataUrl = await new Promise<string>((resolve, reject) => {
106
+ const reader = new FileReader();
107
+ reader.onload = () => {
108
+ resolve(typeof reader.result === "string" ? reader.result : "");
109
+ };
110
+ reader.onerror = () => {
111
+ reject(reader.error ?? new Error("failed to read the selected file"));
112
+ };
113
+ reader.readAsDataURL(file);
114
+ });
115
+ const comma = dataUrl.indexOf(",");
116
+ return comma < 0 ? dataUrl : dataUrl.slice(comma + 1);
117
+ }
118
+
119
+ /** 页面的动作:host 交互收在这里,页面只见数据与回调。 */
120
+ export class ConversationManagerController {
121
+ readonly face: ConversationManagerFace;
122
+
123
+ constructor(private readonly ports: ConversationManagerPorts) {
124
+ this.face = {
125
+ archive: (sessionId) => this.ports.archiveSession(sessionId),
126
+ unarchive: (sessionId) => this.ports.unarchiveSession(sessionId),
127
+ remove: (sessionId) => this.remove(sessionId),
128
+ exportZip: (sessionId) => this.exportZip(sessionId),
129
+ importZip: (file) => this.importZip(file),
130
+ collectGarbage: () => this.collectGarbage(),
131
+ loadUsage: (range) => this.loadUsage(range),
132
+ };
133
+ }
134
+
135
+ private async remove(sessionId: SessionId): Promise<void> {
136
+ await postJson(SESSION_DELETE_PATH, { sessionId });
137
+ await this.ports.refresh();
138
+ }
139
+
140
+ private async exportZip(sessionId: SessionId): Promise<void> {
141
+ const response = await fetch(SESSION_EXPORT_PATH, {
142
+ method: "POST",
143
+ headers: { accept: "application/zip", "content-type": "application/json" },
144
+ body: JSON.stringify({ sessionId }),
145
+ });
146
+ if (!response.ok) {
147
+ const value = (await response.json().catch(() => ({}))) as {
148
+ error?: unknown;
149
+ code?: unknown;
150
+ };
151
+ throw new ConversationManagerRequestError(
152
+ typeof value.error === "string" ? value.error : `请求失败:HTTP ${response.status}`,
153
+ typeof value.code === "string" ? value.code : undefined,
154
+ );
155
+ }
156
+ downloadBlob(
157
+ await response.blob(),
158
+ filenameOf(response.headers.get("content-disposition"), String(sessionId)),
159
+ );
160
+ }
161
+
162
+ private async importZip(file: File): Promise<SessionId> {
163
+ const zip = await zipBase64(file);
164
+ const value = await postJson(SESSION_IMPORT_PATH, { zip });
165
+ await this.ports.refresh();
166
+ return value["sessionId"] as SessionId;
167
+ }
168
+
169
+ private async collectGarbage(): Promise<ConversationManagerGcResult> {
170
+ const value = await postJson(SESSION_GC_PATH, {});
171
+ await this.ports.refresh();
172
+ return {
173
+ orphanSessions: typeof value["orphanSessions"] === "number" ? value["orphanSessions"] : 0,
174
+ orphanEvents: typeof value["orphanEvents"] === "number" ? value["orphanEvents"] : 0,
175
+ stoppedAgents: typeof value["stoppedAgents"] === "number" ? value["stoppedAgents"] : 0,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * 用量统计:host 侧聚合,前端各维度本地折叠。
181
+ * @param range - 时间范围语义键(`all` 不限、`day`/`week` 自然日/周、其余最近 N 天)。
182
+ */
183
+ private async loadUsage(range: UsageRangeKey): Promise<SessionUsageReport> {
184
+ const value = await postJson(SESSION_USAGE_PATH, { range });
185
+ const report = value as unknown as Partial<SessionUsageReport>;
186
+ if (
187
+ report.totals === undefined ||
188
+ !Array.isArray(report.buckets) ||
189
+ !Array.isArray(report.sessions)
190
+ ) {
191
+ throw new ConversationManagerRequestError("用量统计响应不可用", undefined);
192
+ }
193
+ return report as SessionUsageReport;
194
+ }
195
+ }
196
+
197
+ /** 导出文件名优先取 host 给的 Content-Disposition。 */
198
+ function filenameOf(disposition: string | null, sessionId: string): string {
199
+ const matched = disposition === null ? null : /filename="([^"]+)"/u.exec(disposition);
200
+ return matched?.[1] ?? `${sessionId}.zip`;
201
+ }
202
+
203
+ /** 浏览器下载:blob URL + 一次性 anchor;URL 在下一轮事件循环回收。 */
204
+ function downloadBlob(blob: Blob, filename: string): void {
205
+ const url = URL.createObjectURL(blob);
206
+ const anchor = document.createElement("a");
207
+ anchor.href = url;
208
+ anchor.download = filename;
209
+ anchor.rel = "noopener";
210
+ document.body.append(anchor);
211
+ anchor.click();
212
+ anchor.remove();
213
+ window.setTimeout(() => {
214
+ URL.revokeObjectURL(url);
215
+ }, 0);
216
+ }
@@ -0,0 +1,22 @@
1
+ // token 数字的紧凑显示:表格里以总量为主,明细用 K/M/B 压缩。
2
+
3
+ /** 一位小数;三位数以上不留小数。 */
4
+ function trim(value: number): string {
5
+ return value >= 100 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/u, "");
6
+ }
7
+
8
+ /** @param value - token 数(非负整数)。 @returns 紧凑文本,例如 `1.2K`、`44.2B`。 */
9
+ export function formatTokens(value: number): string {
10
+ if (!Number.isFinite(value) || value <= 0) return "0";
11
+ if (value < 1_000) return String(Math.round(value));
12
+ if (value < 1_000_000) return `${trim(value / 1_000)}K`;
13
+ if (value < 1_000_000_000) return `${trim(value / 1_000_000)}M`;
14
+ return `${trim(value / 1_000_000_000)}B`;
15
+ }
16
+
17
+ /** @param value - 百分点(0~100)。 @returns 紧凑百分比,例如 `98.4%`、`100%`。 */
18
+ export function formatPercent(value: number): string {
19
+ if (!Number.isFinite(value) || value <= 0) return "0%";
20
+ const rounded = value >= 100 ? Math.round(value) : Math.round(value * 10) / 10;
21
+ return `${rounded}%`;
22
+ }
@@ -0,0 +1,76 @@
1
+ // 「对话管理」页面:nav 行(sidebar.panellist)与主面板(main keyed)用同一个 id 成对
2
+ // 注册;点击 nav 行经 ctx.layout.selectPanel 校验该 id 在 main 里已注册。
3
+ import type { Context as ClientContext } from "@deepseek-ai/cordis";
4
+ import type { ISessions } from "@deepseek-ai/dsh-api-session-controller/client";
5
+ import type { MainPanelId } from "@deepseek-ai/dsh-client-ui-layout/client";
6
+ import type {} from "@deepseek-ai/dsh-client-locale/client";
7
+ import type {} from "@deepseek-ai/dsh-client-ui-renderer/client";
8
+ import type {} from "@deepseek-ai/dsh-client-ui-sidebar/client";
9
+ import type {} from "@deepseek-ai/dsh-client-ui-session/client";
10
+ import type {} from "@deepseek-ai/dsh-client-ui-workspace/client";
11
+ import type {} from "@deepseek-ai/dsh-api-session-controller/client";
12
+ import { ConversationManagerController } from "./controller.ts";
13
+ import { ConversationManagerPage } from "./ConversationManagerPage.tsx";
14
+ import { ConversationManagerIcon } from "./ConversationManagerIcon.tsx";
15
+ import { en, zh, type ConversationManagerKey } from "./locales.ts";
16
+
17
+ export type { ConversationManagerFace, ConversationManagerPorts } from "./controller.ts";
18
+ export { ConversationManagerController, ConversationManagerRequestError } from "./controller.ts";
19
+ export type { ConversationManagerIconProps } from "./ConversationManagerIcon.tsx";
20
+ export type { ConversationManagerPageProps } from "./ConversationManagerPage.tsx";
21
+ export type { ConversationManagerKey } from "./locales.ts";
22
+
23
+ declare module "@deepseek-ai/dsh-client-ui-slots" {
24
+ interface LocaleNamespaceMap {
25
+ conversationManager: ConversationManagerKey;
26
+ }
27
+ }
28
+
29
+ /** 本包字典的 namespace。 */
30
+ export const NS = "conversationManager";
31
+
32
+ /** nav 行与主面板共用的 id。 */
33
+ export const PANEL_ID = "conversations" as MainPanelId;
34
+
35
+ /** nav 行的位置:紧邻官方 Plugins 行(order 0)。 */
36
+ export const PANEL_ORDER = 1;
37
+
38
+ /** 页面用到的服务。 */
39
+ export const inject = ["slots", "locale", "uiWorkspace", "sessions"];
40
+
41
+ export function apply(ctx: ClientContext): void {
42
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "ui-conversation-manager: dictionaries");
43
+ const t = ctx.locale.bind(NS);
44
+ // ctx.sessions 的类型被别的 client 半的声明占住(SessionStore),按既有做法从服务面取。
45
+ const sessions = ctx.get("sessions") as unknown as ISessions;
46
+ const controller = new ConversationManagerController({
47
+ archiveSession: (sessionId) => ctx.uiWorkspace.archiveSession(sessionId),
48
+ unarchiveSession: (sessionId) => ctx.uiWorkspace.unarchiveSession(sessionId),
49
+ refresh: () => sessions.refresh(),
50
+ });
51
+
52
+ ctx.slots.inject("main", () =>
53
+ ctx.slots.register(
54
+ {
55
+ name: "main",
56
+ key: PANEL_ID,
57
+ locale: NS,
58
+ inject: () => controller.face,
59
+ },
60
+ ConversationManagerPage,
61
+ ),
62
+ );
63
+
64
+ ctx.slots.inject("sidebar.panellist", () =>
65
+ ctx.slots.register(
66
+ {
67
+ name: "sidebar.panellist",
68
+ id: PANEL_ID,
69
+ order: PANEL_ORDER,
70
+ label: () => t("panel"),
71
+ locale: NS,
72
+ },
73
+ ConversationManagerIcon,
74
+ ),
75
+ );
76
+ }
@@ -0,0 +1,152 @@
1
+ /** 「对话管理」页面的文案字典。 */
2
+
3
+ /** 简体中文是 key 真源。 */
4
+ export const zh = {
5
+ panel: "对话管理",
6
+ title: "对话管理",
7
+ search: "搜索会话",
8
+ loading: "正在读取会话…",
9
+ empty: "暂无会话。",
10
+ emptySearch: "没有匹配的会话。",
11
+ archived: "已归档",
12
+ subagent: "子代理",
13
+ showSubagents: "显示子代理会话",
14
+ "view.sessions": "会话",
15
+ "view.usage": "统计",
16
+ "usage.overview": "总览",
17
+ "usage.all": "全部会话",
18
+ "usage.range": "时间范围",
19
+ "usage.range.all": "全部",
20
+ "usage.range.day": "本日",
21
+ "usage.range.week": "本周",
22
+ "usage.range.days": "近 {n} 天",
23
+ "usage.models": "按模型",
24
+ "usage.sessions": "按会话",
25
+ "usage.loading": "正在统计…",
26
+ "usage.empty": "暂无用量数据。",
27
+ "usage.input": "输入",
28
+ "usage.inputWithCache": "输入(含缓存)",
29
+ "usage.output": "输出",
30
+ "usage.cacheInput": "缓存输入",
31
+ "usage.cacheRate": "缓存命中率",
32
+ "usage.reasoning": "推理",
33
+ "usage.total": "合计",
34
+ "usage.events": "事件",
35
+ "usage.subagentOnly": "其中子代理",
36
+ "usage.unknownModel": "未知模型",
37
+ archive: "归档",
38
+ archiveNamed: "归档 {title}",
39
+ unarchive: "取消归档",
40
+ unarchiveNamed: "取消归档 {title}",
41
+ remove: "删除",
42
+ removeNamed: "删除 {title}",
43
+ export: "导出",
44
+ exportNamed: "导出 {title}",
45
+ ungrouped: "未分组",
46
+ "page.previous": "上一页",
47
+ "page.next": "下一页",
48
+ "page.label": "第 {page} / {total} 页",
49
+ "gc.button": "清理孤儿数据",
50
+ "gc.title": "清理孤儿数据",
51
+ "gc.description":
52
+ "会先停止所有运行中的 Agent,期间界面不可操作;随后回收孤儿 subagent 会话与孤儿数据,并执行 VACUUM。",
53
+ "gc.confirm": "开始清理",
54
+ "gc.cancel": "取消",
55
+ "gc.running": "正在清理,请稍候…",
56
+ "gc.done": "已回收 {sessions} 条孤儿会话、{events} 行孤儿数据。",
57
+ import: "导入对话",
58
+ importing: "导入中…",
59
+ imported: "已导入为新会话。",
60
+ confirmTitle: "删除会话",
61
+ confirmDescription: "删除后无法恢复:该会话的内容会从存储中移除。",
62
+ confirmAccept: "删除",
63
+ confirmCancel: "取消",
64
+ close: "关闭",
65
+ "failure.notArchived": "只有已归档的会话可以删除。",
66
+ "failure.live": "会话正在使用中,无法删除。",
67
+ "failure.missing": "会话不存在或已被删除。",
68
+ "failure.other": "操作失败:{reason}",
69
+ "time.now": "刚刚",
70
+ "time.minutes": "{n}分钟",
71
+ "time.hours": "{n}小时",
72
+ "time.days": "{n}天",
73
+ "time.months": "{n}个月",
74
+ "time.years": "{n}年",
75
+ } satisfies Record<string, string>;
76
+
77
+ /** 「对话管理」页面字典的 key 集合。 */
78
+ export type ConversationManagerKey = keyof typeof zh;
79
+
80
+ /** 英文对照表。 */
81
+ export const en = {
82
+ panel: "Conversations",
83
+ title: "Conversations",
84
+ search: "Search sessions",
85
+ loading: "Reading sessions…",
86
+ empty: "No sessions.",
87
+ emptySearch: "No matching sessions.",
88
+ archived: "Archived",
89
+ subagent: "Subagent",
90
+ showSubagents: "Show subagent sessions",
91
+ "view.sessions": "Sessions",
92
+ "view.usage": "Usage",
93
+ "usage.overview": "Overview",
94
+ "usage.all": "All sessions",
95
+ "usage.range": "Time range",
96
+ "usage.range.all": "All",
97
+ "usage.range.day": "Today",
98
+ "usage.range.week": "This week",
99
+ "usage.range.days": "Last {n}d",
100
+ "usage.models": "By model",
101
+ "usage.sessions": "By session",
102
+ "usage.loading": "Reading usage…",
103
+ "usage.empty": "No usage recorded.",
104
+ "usage.input": "Input",
105
+ "usage.inputWithCache": "Input (incl. cache)",
106
+ "usage.output": "Output",
107
+ "usage.cacheInput": "Cache input",
108
+ "usage.cacheRate": "Cache hit rate",
109
+ "usage.reasoning": "Reasoning",
110
+ "usage.total": "Total",
111
+ "usage.events": "Events",
112
+ "usage.subagentOnly": "Subagent share",
113
+ "usage.unknownModel": "Unknown model",
114
+ archive: "Archive",
115
+ archiveNamed: "Archive {title}",
116
+ unarchive: "Unarchive",
117
+ unarchiveNamed: "Unarchive {title}",
118
+ remove: "Delete",
119
+ removeNamed: "Delete {title}",
120
+ export: "Export",
121
+ exportNamed: "Export {title}",
122
+ ungrouped: "Ungrouped",
123
+ "page.previous": "Previous",
124
+ "page.next": "Next",
125
+ "page.label": "Page {page} / {total}",
126
+ "gc.button": "Clean orphan data",
127
+ "gc.title": "Clean orphan data",
128
+ "gc.description":
129
+ "Stops every running Agent first — the UI is blocked meanwhile — then reclaims orphan subagent sessions and orphan rows, and runs VACUUM.",
130
+ "gc.confirm": "Start",
131
+ "gc.cancel": "Cancel",
132
+ "gc.running": "Cleaning, please wait…",
133
+ "gc.done": "Reclaimed {sessions} orphan sessions and {events} orphan rows.",
134
+ import: "Import conversation",
135
+ importing: "Importing…",
136
+ imported: "Imported as a new session.",
137
+ confirmTitle: "Delete session",
138
+ confirmDescription: "This cannot be undone: the session's content is removed from storage.",
139
+ confirmAccept: "Delete",
140
+ confirmCancel: "Cancel",
141
+ close: "Close",
142
+ "failure.notArchived": "Only archived sessions can be deleted.",
143
+ "failure.live": "The session is in use and cannot be deleted.",
144
+ "failure.missing": "The session does not exist or was already deleted.",
145
+ "failure.other": "Action failed: {reason}",
146
+ "time.now": "now",
147
+ "time.minutes": "{n}min",
148
+ "time.hours": "{n}h",
149
+ "time.days": "{n}d",
150
+ "time.months": "{n}mo",
151
+ "time.years": "{n}y",
152
+ } satisfies Record<ConversationManagerKey, string>;
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** 「对话管理」页面的 host 半:没有 host 面(页面只读已有服务与路由)。 */
2
+ export function apply(): void {}