@morlay/ui-conversation-message-actions 0.0.11 → 0.0.13

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.
Files changed (42) hide show
  1. package/README.md +0 -6
  2. package/dist/client.js +831 -0
  3. package/dist/index.d.mts +105 -0
  4. package/dist/index.mjs +3 -0
  5. package/dist/invariant.d.mts +7 -0
  6. package/dist/invariant.mjs +8 -0
  7. package/dist/plan-Bw4zoI4N.d.mts +86 -0
  8. package/dist/plan.d.mts +2 -0
  9. package/dist/plan.mjs +225 -0
  10. package/dist/src-CXYW0Bc0.mjs +408 -0
  11. package/dist/testing.d.mts +707 -0
  12. package/dist/testing.mjs +23690 -0
  13. package/package.json +60 -31
  14. package/src/client/chat-node/MessageEditDialog.module.css +24 -0
  15. package/src/client/chat-node/MessageEditDialog.tsx +71 -0
  16. package/src/client/chat-node/MessageIconActions.module.css +86 -0
  17. package/src/client/chat-node/MessageIconActions.tsx +189 -0
  18. package/src/client/chat-node/MessageItem.module.css +290 -0
  19. package/src/client/chat-node/MessageItem.tsx +198 -0
  20. package/src/client/chat-node/message-chrome.ts +61 -0
  21. package/src/client/chat-node/register.ts +33 -0
  22. package/src/client/chat-node/use-calendar-day.ts +23 -0
  23. package/src/client/controller.ts +327 -0
  24. package/src/client/css-modules.d.ts +4 -0
  25. package/src/client/import-action.tsx +74 -0
  26. package/src/client/index.ts +50 -0
  27. package/src/index.ts +650 -0
  28. package/src/invariant.ts +13 -0
  29. package/src/plan.ts +322 -0
  30. package/src/shared.ts +167 -0
  31. package/src/testing.ts +151 -0
  32. package/src/types.ts +73 -0
  33. package/lib/client.js +0 -2243
  34. package/lib/client.js.map +0 -1
  35. package/lib/index.d.mts +0 -211
  36. package/lib/index.d.mts.map +0 -1
  37. package/lib/index.mjs +0 -685
  38. package/lib/index.mjs.map +0 -1
  39. package/lib/invariant.d.mts +0 -15
  40. package/lib/invariant.d.mts.map +0 -1
  41. package/lib/invariant.mjs +0 -22
  42. package/lib/invariant.mjs.map +0 -1
@@ -0,0 +1,327 @@
1
+ import type { Context as ClientContext } from "@deepseek-ai/cordis";
2
+ import type {
3
+ ISessions,
4
+ SessionFace,
5
+ SessionSnapshot,
6
+ } from "@deepseek-ai/dsh-api-session-controller/client";
7
+ import type { ObservableSnapshot, SnapshotStore } from "@deepseek-ai/dsh-client-store";
8
+ import { createSnapshotStore } from "@deepseek-ai/dsh-client-store";
9
+ import type { SessionId } from "@deepseek-ai/dsh-session";
10
+ import {
11
+ SESSION_EDITOR_PATH,
12
+ type EditableMessageBlock,
13
+ type SessionEditorOperation,
14
+ type SessionEditorOperationResult,
15
+ type SessionEditorTimeline,
16
+ type VersionOperation,
17
+ } from "../shared.ts";
18
+
19
+ export interface SessionEditorState {
20
+ status: "idle" | "loading" | "ready" | "error";
21
+ error: string | null;
22
+ pending: VersionOperation | "import" | null;
23
+ timeline: SessionEditorTimeline | null;
24
+ }
25
+
26
+ export interface SessionEditorFace {
27
+ hooks: { sessionEditor: ObservableSnapshot<SessionEditorState> };
28
+ acquire(): () => void;
29
+ load(): void;
30
+ edit(
31
+ message: EditableMessageBlock,
32
+ text: string,
33
+ cascade: "truncate" | "preserve",
34
+ ): Promise<boolean>;
35
+ retry(turn: number, cascade: "truncate" | "preserve"): Promise<boolean>;
36
+ reroll(): Promise<boolean>;
37
+ rewind(toBoundary: number): Promise<boolean>;
38
+
39
+ importSession(file: File): Promise<boolean>;
40
+ openVersion(sessionId: string): Promise<void>;
41
+ }
42
+
43
+ function messageOf(error: unknown): string {
44
+ return error instanceof Error ? error.message : String(error);
45
+ }
46
+
47
+ function conversationRevision(snapshot: SessionSnapshot): string {
48
+ // 新版 SessionSnapshot 已无 turnEnds(会话级事件窗口不再暴露轮次边界);
49
+ // 用生命周期字段作为会话变化指纹即可。
50
+ return [snapshot.openState, snapshot.removed, snapshot.hasMore].join("|");
51
+ }
52
+
53
+ export class SessionEditorController {
54
+ readonly store: SnapshotStore<SessionEditorState> = createSnapshotStore<SessionEditorState>({
55
+ status: "idle",
56
+ error: null,
57
+ pending: null,
58
+ timeline: null,
59
+ });
60
+
61
+ readonly face: SessionEditorFace;
62
+ private readonly ctx: ClientContext;
63
+ private readonly sessions: ISessions;
64
+ private sessionSource: SessionFace | undefined;
65
+ private sessionSourceDispose: (() => void) | undefined;
66
+ private sessionRevision: string | undefined;
67
+ private disposed = false;
68
+ private users = 0;
69
+ private readonly navigationWaits = new Set<() => void>();
70
+
71
+ constructor(
72
+ ctx: ClientContext,
73
+ private readonly sessionId: SessionId,
74
+ ) {
75
+ this.ctx = ctx;
76
+ this.sessions = ctx.get("sessions") as unknown as ISessions;
77
+ this.face = {
78
+ hooks: { sessionEditor: this.store },
79
+ acquire: () => {
80
+ this.users += 1;
81
+ if (this.users === 1 && this.disposed) this.revive();
82
+ return () => this.release();
83
+ },
84
+ load: () => {
85
+ void this.load();
86
+ },
87
+ edit: (message, text, cascade) =>
88
+ this.mutate({
89
+ action: "edit",
90
+ sessionId: this.sessionId,
91
+ eventSeq: message.eventSeq,
92
+ blockIndex: message.blockIndex,
93
+ text,
94
+ cascade,
95
+ }),
96
+ retry: (turn, cascade) =>
97
+ this.mutate({ action: "retry", sessionId: this.sessionId, turn, cascade }),
98
+ reroll: () => this.mutate({ action: "reroll", sessionId: this.sessionId }),
99
+ rewind: (toBoundary) =>
100
+ this.mutate({ action: "rewind", sessionId: this.sessionId, toBoundary }),
101
+ importSession: (file) => this.importSession(file),
102
+ openVersion: (sessionId) => this.openWhenListed(sessionId as SessionId),
103
+ };
104
+ this.observe();
105
+ }
106
+
107
+ private observe(): void {
108
+ this.sessionSource = undefined;
109
+ this.sessionSourceDispose?.();
110
+ this.bindSessionSource();
111
+ this.sessions.list.subscribe(() => this.invalidate());
112
+ }
113
+
114
+ private bindSessionSource(): void {
115
+ const source = this.sessions.binding(this.sessionId)?.session;
116
+ if (source === this.sessionSource) return;
117
+ this.sessionSourceDispose?.();
118
+ this.sessionSource = source;
119
+ this.sessionRevision =
120
+ source === undefined ? undefined : conversationRevision(source.getSnapshot());
121
+ this.sessionSourceDispose = source?.subscribe(() => {
122
+ this.invalidate();
123
+ });
124
+ }
125
+
126
+ private invalidate(): void {
127
+ if (this.disposed || this.store.getSnapshot().status === "idle") return;
128
+ // 会话状态变化 → 刷新投影(debounce 由调用方按需)。
129
+ void this.load();
130
+ }
131
+
132
+ private release(): void {
133
+ this.users -= 1;
134
+ if (this.users <= 0) this.dispose();
135
+ }
136
+
137
+ private dispose(): void {
138
+ if (this.disposed) return;
139
+ this.disposed = true;
140
+ this.sessionSourceDispose?.();
141
+ this.sessionSourceDispose = undefined;
142
+ this.sessionSource = undefined;
143
+ this.sessionRevision = undefined;
144
+ }
145
+
146
+ private revive(): void {
147
+ this.disposed = false;
148
+ this.observe();
149
+ void this.load();
150
+ }
151
+
152
+ async load(): Promise<void> {
153
+ if (this.disposed) return;
154
+ this.store.update((state) => {
155
+ state.status = "loading";
156
+ state.error = null;
157
+ });
158
+ try {
159
+ const response = await fetch(
160
+ `${SESSION_EDITOR_PATH}?sessionId=${encodeURIComponent(this.sessionId)}`,
161
+ {
162
+ method: "GET",
163
+ headers: { accept: "application/json" },
164
+ cache: "no-store",
165
+ },
166
+ );
167
+ const value = (await response.json()) as unknown;
168
+ if (this.disposed) return;
169
+ if (response.ok) {
170
+ this.store.update((state) => {
171
+ state.status = "ready";
172
+ state.error = null;
173
+ state.timeline = value as SessionEditorTimeline;
174
+ });
175
+ } else {
176
+ const error = (value as { error?: unknown })["error"];
177
+ this.store.update((state) => {
178
+ state.status = "error";
179
+ state.error =
180
+ typeof error === "string" ? error : `请求失败:HTTP ${String(response.status)}`;
181
+ });
182
+ }
183
+ } catch (error) {
184
+ if (this.disposed) return;
185
+ this.store.update((state) => {
186
+ state.status = "error";
187
+ state.error = messageOf(error);
188
+ });
189
+ }
190
+ }
191
+
192
+ private async mutate(operation: SessionEditorOperation): Promise<boolean> {
193
+ // 只拦截并发操作;不要求 status === "ready"——编辑/重试的数据来自客户端
194
+ // conversation 节点,与 timeline 加载无关,status 为 idle 时也可发起请求。
195
+ const current = this.store.getSnapshot();
196
+ if (current.pending !== null) return false;
197
+ this.store.update((state) => {
198
+ state.pending = operation.action;
199
+ state.error = null;
200
+ });
201
+ try {
202
+ const response = await fetch(SESSION_EDITOR_PATH, {
203
+ method: "POST",
204
+ headers: { accept: "application/json", "content-type": "application/json" },
205
+ body: JSON.stringify(operation),
206
+ });
207
+ const value = (await response.json()) as unknown;
208
+ if (this.disposed) return true;
209
+ if (!response.ok) {
210
+ const error = (value as { error?: unknown })["error"];
211
+ throw new Error(
212
+ typeof error === "string" ? error : `请求失败:HTTP ${String(response.status)}`,
213
+ );
214
+ }
215
+ this.store.update((state) => {
216
+ state.pending = null;
217
+ });
218
+ const result = value as SessionEditorOperationResult;
219
+ // 仅 fork 产生新 id 时需要等列表发布并打开新版本;就地编辑不改 id。
220
+ if (String(result.sessionId) !== String(this.sessionId)) {
221
+ await this.openWhenListed(result.sessionId as SessionId);
222
+ return true;
223
+ }
224
+ // 就地编辑:rewind 删除事件,append-only 事件流无法表达删除——seq
225
+ // 回退只做增量,被剪掉的旧节点残留。优先会话级刷新(resync 重置窗口
226
+ // 并重新拉取历史);不可用时回退整页重载。
227
+ const face = this.sessions.binding(this.sessionId)?.session;
228
+ const resync = (face as unknown as { resync?: () => Promise<void> }).resync;
229
+ if (resync !== undefined) {
230
+ try {
231
+ await resync.call(face);
232
+ void this.load();
233
+ return true;
234
+ } catch {
235
+ // fall through to full reload
236
+ }
237
+ }
238
+ location.reload();
239
+ return true;
240
+ } catch (error) {
241
+ if (this.disposed) return false;
242
+ this.store.update((state) => {
243
+ state.pending = null;
244
+ state.error = messageOf(error);
245
+ });
246
+ return false;
247
+ }
248
+ }
249
+
250
+ private async importSession(file: File): Promise<boolean> {
251
+ const current = this.store.getSnapshot();
252
+ if (current.pending !== null) return false;
253
+ this.store.update((state) => {
254
+ state.pending = "import";
255
+ state.error = null;
256
+ });
257
+ try {
258
+ const zip = await new Promise<string>((resolve, reject) => {
259
+ const reader = new FileReader();
260
+ reader.onload = () => {
261
+ const dataUrl = typeof reader.result === "string" ? reader.result : "";
262
+ const comma = dataUrl.indexOf(",");
263
+ resolve(comma < 0 ? dataUrl : dataUrl.slice(comma + 1));
264
+ };
265
+ reader.onerror = () =>
266
+ reject(reader.error ?? new Error("failed to read the selected file"));
267
+ reader.readAsDataURL(file);
268
+ });
269
+ const response = await fetch("/api/session.import", {
270
+ method: "POST",
271
+ headers: { accept: "application/json", "content-type": "application/json" },
272
+ body: JSON.stringify({ zip, sessionId: this.sessionId }),
273
+ });
274
+ const value = (await response.json()) as unknown;
275
+ if (this.disposed) return true;
276
+ if (!response.ok) {
277
+ const error = (value as { error?: unknown })["error"];
278
+ throw new Error(
279
+ typeof error === "string" ? error : `请求失败:HTTP ${String(response.status)}`,
280
+ );
281
+ }
282
+ this.store.update((state) => {
283
+ state.pending = null;
284
+ });
285
+ // 覆盖成功但**不能**走 resync:resync 重开事件流时 observeSession 仍
286
+ // 优先读 live session(rewind 已截断其内存 log,append 只写 DB),会
287
+ // 再读到空/截断数据。整页重载让会话从 live 卸载,冷读 DB 完整数据。
288
+ location.reload();
289
+ return true;
290
+ } catch (error) {
291
+ if (this.disposed) return false;
292
+ this.store.update((state) => {
293
+ state.pending = null;
294
+ state.error = messageOf(error);
295
+ });
296
+ return false;
297
+ }
298
+ }
299
+
300
+ private openWhenListed(sessionId: SessionId): Promise<void> {
301
+ if (this.sessions.list.getSnapshot().byId[sessionId] !== undefined) {
302
+ this.sessions.open(sessionId);
303
+ return Promise.resolve();
304
+ }
305
+ return new Promise((resolve) => {
306
+ let settled = false;
307
+ let dispose = (): void => {};
308
+ const finish = (open: boolean): void => {
309
+ if (settled) return;
310
+ settled = true;
311
+ dispose();
312
+ this.navigationWaits.delete(cancel);
313
+ if (open) this.sessions.open(sessionId);
314
+ resolve();
315
+ };
316
+ const cancel = (): void => {
317
+ finish(false);
318
+ };
319
+ this.navigationWaits.add(cancel);
320
+ dispose = this.sessions.list.subscribe(() => {
321
+ if (this.sessions.list.getSnapshot().byId[sessionId] === undefined) return;
322
+ finish(true);
323
+ });
324
+ if (this.sessions.list.getSnapshot().byId[sessionId] !== undefined) finish(true);
325
+ });
326
+ }
327
+ }
@@ -0,0 +1,4 @@
1
+ declare module "*.module.css" {
2
+ const classes: Record<string, string>;
3
+ export default classes;
4
+ }
@@ -0,0 +1,74 @@
1
+ import { useCallback, useRef, useState, type ReactElement } from "react";
2
+ import { Button, IconProps } from "@deepseek-ai/dsh-client-ui-primitives";
3
+ import type { SessionEditorState } from "./controller.ts";
4
+
5
+ export interface SessionImportActionProps {
6
+ useSessionEditor: <S>(sel: (s: SessionEditorState) => S) => S;
7
+
8
+ importSession: (file: File) => Promise<boolean>;
9
+ }
10
+
11
+ export const IconUpload = ({ size = 14, className }: IconProps) => (
12
+ <svg
13
+ width={size}
14
+ height={size}
15
+ className={className}
16
+ viewBox="0 0 24 24"
17
+ fill="none"
18
+ xmlns="http://www.w3.org/2000/svg"
19
+ stroke="currentColor"
20
+ stroke-width="2"
21
+ stroke-linecap="round"
22
+ stroke-linejoin="round"
23
+ >
24
+ <path d="M12 3v12" />
25
+ <path d="m17 8-5-5-5 5" />
26
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
27
+ </svg>
28
+ );
29
+
30
+ export function SessionImportAction({
31
+ useSessionEditor,
32
+ importSession,
33
+ }: SessionImportActionProps): ReactElement {
34
+ const inputRef = useRef<HTMLInputElement>(null);
35
+ const importing = useSessionEditor((s) => s.pending === "import");
36
+ const error = useSessionEditor((s) => s.error);
37
+ const [busy, setBusy] = useState(false);
38
+
39
+ const onPick = useCallback(
40
+ (file: File | undefined) => {
41
+ if (file === undefined) return;
42
+ setBusy(true);
43
+ void importSession(file).finally(() => setBusy(false));
44
+ },
45
+ [importSession],
46
+ );
47
+
48
+ const working = busy || importing;
49
+ return (
50
+ <>
51
+ <Button
52
+ size="sm"
53
+ icon={<IconUpload />}
54
+ disabled={working}
55
+ aria-busy={working}
56
+ aria-label={working ? "导入中…" : "导入会话"}
57
+ title={error ?? "导入会话:用导出的 zip 覆盖当前会话内容"}
58
+ onClick={() => inputRef.current?.click()}
59
+ >
60
+ <input
61
+ ref={inputRef}
62
+ type="file"
63
+ accept=".zip,application/zip"
64
+ style={{ display: "none" }}
65
+ onChange={(e) => {
66
+ const file = e.target.files?.[0];
67
+ e.target.value = "";
68
+ onPick(file);
69
+ }}
70
+ />
71
+ </Button>
72
+ </>
73
+ );
74
+ }
@@ -0,0 +1,50 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import type {} from "@deepseek-ai/dsh-client-ui-conversation/client";
3
+ import type {} from "@deepseek-ai/dsh-client-ui-chat/client";
4
+ import type {} from "@deepseek-ai/dsh-client-ui-renderer/client";
5
+ import type { SessionId } from "@deepseek-ai/dsh-session";
6
+ import { SessionEditorController } from "./controller.ts";
7
+ import { registerChatNodeRenderers } from "./chat-node/register.ts";
8
+ import { SessionImportAction } from "./import-action.tsx";
9
+
10
+ export const inject = ["slots", "conversation", "connection", "sessions"];
11
+
12
+ export function apply(ctx: Context): void {
13
+ const controllers = new Map<SessionId, SessionEditorController>();
14
+ const controllerFor = (sessionId: SessionId): SessionEditorController => {
15
+ let controller = controllers.get(sessionId);
16
+ if (controller === undefined) {
17
+ controller = new SessionEditorController(ctx, sessionId);
18
+ controllers.set(sessionId, controller);
19
+ }
20
+ return controller;
21
+ };
22
+
23
+ ctx.on("connection/reset", () => {
24
+ for (const controller of controllers.values()) void controller.load();
25
+ });
26
+
27
+ // 新版 `conversation.chat.node` 是 keyed slot(reuse key 即替换该节点的
28
+ // 渲染器);register 内部经 ctx.slots.inject 等待声明。
29
+ registerChatNodeRenderers(ctx, controllerFor);
30
+
31
+ // 会话头部「导入会话」入口:导出按钮旁(header.utilities list 槽),
32
+ // 用导出的 zip 覆盖当前会话内容(host 端 `/api/session.import`)。
33
+ ctx.slots.inject("conversation.session.header.utilities", () =>
34
+ ctx.slots.register(
35
+ {
36
+ name: "conversation.session.header.utilities",
37
+ id: "session-editor.import",
38
+ inject: (sessionId: SessionId) => {
39
+ const face = controllerFor(sessionId).face;
40
+ return {
41
+ hooks: face.hooks,
42
+ // face 方法是闭包(已绑定 this),直接透传。
43
+ importSession: (file: File) => face.importSession(file),
44
+ };
45
+ },
46
+ } as never,
47
+ SessionImportAction as never,
48
+ ),
49
+ );
50
+ }