@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
package/src/index.ts ADDED
@@ -0,0 +1,650 @@
1
+ import { Service, type Context } from "@deepseek-ai/cordis";
2
+ import type { AssistantMessage, UserMessage } from "@deepseek-ai/dsh-llm";
3
+ import type {
4
+ Session,
5
+ SessionEvent,
6
+ SessionId,
7
+ SurfaceEventType,
8
+ SurfaceIntent,
9
+ } from "@deepseek-ai/dsh-session";
10
+ import {
11
+ SessionBranchError,
12
+ balanceRewindPrefix,
13
+ type BranchBoundary,
14
+ type BranchTimeline,
15
+ } from "@morlay/session-branch";
16
+ import type {
17
+ EditOperation,
18
+ EditableMessageBlock,
19
+ RerollOperation,
20
+ RetryOperation,
21
+ RetryableTurn,
22
+ SessionEditorResult,
23
+ } from "./types.ts";
24
+ import {
25
+ SESSION_EDITOR_PATH,
26
+ type SessionEditorOperation,
27
+ type SessionEditorOperationResult,
28
+ type SessionEditorTimeline,
29
+ toTimelinePayload,
30
+ } from "./shared.ts";
31
+ import {
32
+ closedTurns,
33
+ editableMessages,
34
+ editPlan,
35
+ retryPlan,
36
+ rerollPlan,
37
+ retryableTurns,
38
+ } from "./plan.ts";
39
+
40
+ export type { CascadePolicy, EditableBlockKind } from "@morlay/session-branch";
41
+ export type {
42
+ EditableMessageBlock,
43
+ EditOperation,
44
+ RerollOperation,
45
+ RetryOperation,
46
+ RetryableTurn,
47
+ RewindOperation,
48
+ SessionEditorOperation,
49
+ SessionEditorResult,
50
+ } from "./types.ts";
51
+
52
+ export { closedTurns, editableMessages, retryableTurns };
53
+ export { SESSION_BRANCH_VERSION_SCHEMA } from "@morlay/session-branch";
54
+
55
+ export type { BranchTimeline, SessionBranchVersionEvent } from "@morlay/session-branch";
56
+
57
+ declare module "@deepseek-ai/cordis" {
58
+ interface Context {
59
+ sessionEditor: SessionEditor;
60
+ }
61
+ }
62
+
63
+ export interface EditorAgent {
64
+ readonly session: Session;
65
+ followup(message: UserMessage): void;
66
+ /** 等待 agent 到达 quiescence(当前 turn/任务结束后 resolve)。 */
67
+ whenIdle(): Promise<void>;
68
+ /**
69
+ * 队列中是否有待处理输入(next-turn / next-step)。rewind 截断会删除这些
70
+ * 输入所属轮次的事件,残留的 inbox 状态会与截断后的 log 失配,使后续
71
+ * splice 落库后无法从日志重放——编辑前必须清空。
72
+ */
73
+ readonly inboxPending: boolean;
74
+ /** 清空待处理输入(落库 canceled splice,须在 rewind 前调用)。 */
75
+ clearInbox(): void;
76
+ }
77
+
78
+ export interface EditorAgentHandle {
79
+ readonly agent: EditorAgent;
80
+ dispose(): Promise<void>;
81
+ }
82
+
83
+ export interface EditorAgentRegistry {
84
+ get(sessionId: SessionId): EditorAgent | undefined;
85
+ create(options: {
86
+ sessionId?: SessionId;
87
+ seed?: readonly SessionEvent[];
88
+ meta?: {
89
+ cwd?: string;
90
+ parentSession?: SessionId;
91
+ seedLength?: number;
92
+ agentPreset?: string;
93
+ };
94
+ agentOptions?: { provider: string; model: string; maxTokens?: number };
95
+ }): Promise<EditorAgentHandle>;
96
+
97
+ resume(options: {
98
+ resumeSessionId: SessionId;
99
+ agentOptions?: { provider: string; model: string; maxTokens?: number };
100
+ }): Promise<EditorAgentHandle>;
101
+ }
102
+
103
+ function appendLogSeedEvent(
104
+ events: SessionEvent[],
105
+ type: string,
106
+ data: unknown,
107
+ ignorable = false,
108
+ ): void {
109
+ events.push({
110
+ type: type as SessionEvent["type"],
111
+ seq: events.length,
112
+ time: Date.now(),
113
+ data: data as SessionEvent["data"],
114
+ ...(ignorable ? { ignorable: true as const } : {}),
115
+ } as SessionEvent);
116
+ }
117
+
118
+ function appendSurfaceSeedEvent<T extends SurfaceEventType>(
119
+ events: SessionEvent[],
120
+ type: T,
121
+ data: import("@deepseek-ai/dsh-session").SessionEvent<T>["data"],
122
+ intent: SurfaceIntent,
123
+ ): void {
124
+ events.push({
125
+ type,
126
+ seq: events.length,
127
+ time: Date.now(),
128
+ data,
129
+ surfaceOp: intent.surfaceOp,
130
+ ...(intent.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: intent.sourceEventSeqs }),
131
+ } as SessionEvent<T>);
132
+ }
133
+
134
+ function appendManualTurn(
135
+ events: SessionEvent[],
136
+ manual: { turn: number; user: UserMessage; assistant: AssistantMessage },
137
+ ): void {
138
+ const { turn, user, assistant } = manual;
139
+ appendLogSeedEvent(events, "turn/start", { turn });
140
+ appendSurfaceSeedEvent(events, "user/message", user, { surfaceOp: "append" });
141
+ appendLogSeedEvent(events, "step/start", { turn, step: 1 });
142
+ appendSurfaceSeedEvent(
143
+ events,
144
+ "assistant/message",
145
+ { turn, step: 1, message: assistant },
146
+ {
147
+ surfaceOp: "append",
148
+ sourceEventSeqs: [],
149
+ },
150
+ );
151
+ appendLogSeedEvent(events, "step/end", { turn, step: 1 });
152
+ appendLogSeedEvent(events, "turn/end", {
153
+ turn,
154
+ reason: { kind: "completed" },
155
+ });
156
+ }
157
+
158
+ function appendSeedSuffixLive(session: Session, seedSuffix: readonly SessionEvent[]): void {
159
+ for (const event of seedSuffix) {
160
+ // 版本效果事件携带 ignorable 标记(上游类型无此字段,duck-type 读取)。
161
+ const ignorable = (event as { ignorable?: boolean }).ignorable === true;
162
+ if (ignorable) {
163
+ const s = session as unknown as {
164
+ log: SessionEvent[];
165
+ eventsSnapshot?: unknown;
166
+ };
167
+ // ignorable 语义:live log 保留、不落 canonical log。session.append 不
168
+ // 保留 ignorable 标记,因此直接 push 内存 log(不发布);seq 按 log
169
+ // 续接重编号(seedSuffix 内部编号从 0 起)。
170
+ s.log.push({ ...event, seq: s.log.length } as SessionEvent);
171
+ s.eventsSnapshot = undefined;
172
+ continue;
173
+ }
174
+ const s = session as unknown as {
175
+ append(
176
+ type: string,
177
+ data: unknown,
178
+ opts?: { surfaceOp?: unknown; sourceEventSeqs?: readonly number[] },
179
+ ): SessionEvent;
180
+ };
181
+ const raw = event as SessionEvent & {
182
+ surfaceOp?: unknown;
183
+ sourceEventSeqs?: readonly number[];
184
+ };
185
+ if (raw.surfaceOp !== undefined) {
186
+ s.append(event.type, event.data, {
187
+ surfaceOp: raw.surfaceOp,
188
+ ...(raw.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: raw.sourceEventSeqs }),
189
+ });
190
+ } else {
191
+ s.append(event.type, event.data);
192
+ }
193
+ }
194
+ }
195
+
196
+ export class SessionEditor extends Service {
197
+ static inject = ["sessionBranch", "sessionPersistence", "sessions"];
198
+
199
+ constructor(ctx: Context) {
200
+ super(ctx, "sessionEditor");
201
+ // HTTP 路由随类构造注册(dsh 用 default 类插件,apply 不被调用);
202
+ // bundles 顺序保证 webserver 先于本类实例化。
203
+ registerHttpRoutes(ctx);
204
+ }
205
+
206
+ readBranchPrefix(
207
+ id: SessionId,
208
+ atSeq?: number,
209
+ mode?: "after" | "before",
210
+ signal?: AbortSignal,
211
+ ): Promise<BranchBoundary> {
212
+ return this.ctx.sessionBranch.readBranchPrefix(id, atSeq, mode, signal);
213
+ }
214
+
215
+ fork(
216
+ sourceId: SessionId,
217
+ atSeq?: number,
218
+ childSessionId?: SessionId,
219
+ meta?: { cwd?: string; agentPreset?: string },
220
+ signal?: AbortSignal,
221
+ ): Promise<SessionId> {
222
+ return this.ctx.sessionBranch.forkFrom(
223
+ sourceId,
224
+ {
225
+ ...(atSeq === undefined ? {} : { atSeq }),
226
+ ...(childSessionId === undefined ? {} : { childSessionId }),
227
+ ...(meta === undefined ? {} : { meta }),
228
+ },
229
+ signal,
230
+ );
231
+ }
232
+
233
+ rewind(id: SessionId, toBoundary: number, signal?: AbortSignal) {
234
+ return this.ctx.sessionBranch.rewind(id, toBoundary, signal);
235
+ }
236
+
237
+ timeline(sessionId: SessionId, signal?: AbortSignal): Promise<BranchTimeline> {
238
+ return this.ctx.sessionBranch.timeline(sessionId, signal);
239
+ }
240
+
241
+ edit(operation: EditOperation, signal?: AbortSignal): Promise<SessionEditorResult> {
242
+ return this.branchOperation(operation, signal);
243
+ }
244
+
245
+ reroll(operation: RerollOperation, signal?: AbortSignal): Promise<SessionEditorResult> {
246
+ return this.branchOperation(operation, signal);
247
+ }
248
+
249
+ retry(operation: RetryOperation, signal?: AbortSignal): Promise<SessionEditorResult> {
250
+ return this.branchOperation(operation, signal);
251
+ }
252
+
253
+ async editableMessages(
254
+ sessionId: SessionId,
255
+ signal?: AbortSignal,
256
+ ): Promise<EditableMessageBlock[]> {
257
+ const events = await this.readEvents(sessionId, signal);
258
+ return editableMessages(closedTurns(events));
259
+ }
260
+
261
+ async retryableTurns(sessionId: SessionId, signal?: AbortSignal): Promise<RetryableTurn[]> {
262
+ const events = await this.readEvents(sessionId, signal);
263
+ return retryableTurns(closedTurns(events));
264
+ }
265
+
266
+ private async branchOperation(
267
+ operation: EditOperation | RerollOperation | RetryOperation,
268
+ signal?: AbortSignal,
269
+ ): Promise<SessionEditorResult> {
270
+ signal?.throwIfAborted();
271
+ const events = await this.readEvents(operation.sessionId, signal);
272
+ const turns = closedTurns(events);
273
+ const plan =
274
+ operation.action === "edit"
275
+ ? editPlan(operation, turns)
276
+ : operation.action === "retry"
277
+ ? retryPlan(operation, turns)
278
+ : rerollPlan(operation, turns);
279
+ // rewind 前解析模型配置:就地编辑可能截断最后的 request/header
280
+ // (编辑第一轮 boundary = -1 清空全部),重放 agent 需要 provider/model。
281
+ const headerConfig = events.findLast((event) => event.type === "request/header")?.data.header
282
+ .config;
283
+
284
+ // 派生 seed 后缀:版本效果 + 可选手工回合。版本事件对核心是 ignorable,
285
+ // 保证非 branch 读者可安全跳过。
286
+ const seedSuffix: SessionEvent[] = [];
287
+ appendLogSeedEvent(seedSuffix, "session-branch/version", plan.version, true);
288
+ if (plan.manualTurn !== undefined) appendManualTurn(seedSuffix, plan.manualTurn);
289
+
290
+ // 就地编辑:不创建新会话、不改变 id。需要重放排队输入时,先在 rewind
291
+ // 前确保 agent 就绪(live agent 等其停;cold 先 resume)——rewind 截断后
292
+ // agent 无法再以完整会话 resume,且重放失败不应让截断静默丢弃内容。
293
+ const replay = await this.prepareReplay(
294
+ operation.sessionId,
295
+ plan.queuedUsers,
296
+ signal,
297
+ headerConfig,
298
+ );
299
+
300
+ const turnIndex = turns.findIndex((turn) => turn.startSeq === plan.anchorSeq);
301
+ const boundary =
302
+ plan.rewindBoundary !== undefined
303
+ ? plan.rewindBoundary
304
+ : turnIndex <= 0
305
+ ? -1
306
+ : turns[turnIndex - 1]!.endSeq!;
307
+ const live = this.ctx.sessions.get(operation.sessionId);
308
+ await this.ctx.sessionBranch.rewind(operation.sessionId, boundary, signal);
309
+ if (seedSuffix.length > 0) {
310
+ if (live !== undefined) {
311
+ // live:版本效果 push 内存 log(ignorable 保留)、manualTurn 走 append,
312
+ // 同步 cursor 后显式 flush(push 不发布、不进缓冲,cursor 会落后)。
313
+ appendSeedSuffixLive(live, seedSuffix);
314
+ this.ctx.sessionBranch.syncLiveCursor(operation.sessionId);
315
+ await this.ctx.sessions.flush(live);
316
+ } else {
317
+ // cold:续写 seq 从平衡后的保留前缀接续(exclusive 截断可能残留
318
+ // 孤儿 step/start,落盘 log 对 token meter 重放非法)。
319
+ const rawKeepLength = boundary + (plan.rewindBoundary === undefined ? 1 : 0);
320
+ const keepLength = balanceRewindPrefix(events.slice(0, rawKeepLength)).length;
321
+ const renumbered = seedSuffix.map(
322
+ (event, index) =>
323
+ ({
324
+ ...event,
325
+ seq: keepLength + index,
326
+ }) as SessionEvent,
327
+ );
328
+ await this.ctx.sessionPersistence.append(operation.sessionId, renumbered);
329
+ }
330
+ }
331
+
332
+ // 发起新的 user prompt:把排队输入交给就绪的 agent(rewind 后其 session
333
+ // 已被截断,followup 基于截断后历史开新轮重放)。无 agent(agents 服务
334
+ // 缺失)时退化为已 durable 的就地版本。
335
+ let queuedTurns = 0;
336
+ if (replay.agent !== undefined && plan.queuedUsers.length > 0) {
337
+ for (const message of plan.queuedUsers) replay.agent.followup(message);
338
+ await this.ctx.sessions.flush(replay.agent.session);
339
+ queuedTurns = plan.queuedUsers.length;
340
+ }
341
+ return {
342
+ sessionId: operation.sessionId,
343
+ queuedTurns,
344
+ // 操作后是否仍有 live owner(prepareReplay 可能 resume 出 agent)。
345
+ live: this.ctx.sessions.get(operation.sessionId) !== undefined,
346
+ };
347
+ }
348
+
349
+ private async readEvents(
350
+ sessionId: SessionId,
351
+ signal?: AbortSignal,
352
+ ): Promise<readonly SessionEvent[]> {
353
+ const live = this.ctx.sessions.get(sessionId);
354
+ if (live !== undefined) return live.snapshotEvents();
355
+ // cold:读原始事件(loadStored 不补 closers)——inspect 会把未闭合 log
356
+ // 补成闭合,编辑未闭合轮次的 user 消息会走错边界。
357
+ const branch = this.ctx.sessionBranch as unknown as {
358
+ readRawEvents(
359
+ id: SessionId,
360
+ signal?: AbortSignal,
361
+ ): Promise<{ meta: unknown; events: readonly SessionEvent[] }>;
362
+ };
363
+ return (await branch.readRawEvents(sessionId, signal)).events;
364
+ }
365
+
366
+ /**
367
+ * rewind 前确保 agent 可驱动重放:live agent 先等待其停下(rewind 会截断其
368
+ * session 内存 log,须在 quiescence 后执行);cold 会话先 resume 出驻留
369
+ * agent(此时会话完整,resume 的 prepare 不与截断冲突)。agents 服务缺失或
370
+ * 无需重放时返回空——调用方退化为就地截断版本。
371
+ */
372
+ private async prepareReplay(
373
+ sessionId: SessionId,
374
+ queuedUsers: readonly UserMessage[],
375
+ signal: AbortSignal | undefined,
376
+ headerConfig?: { provider?: string; model?: string; maxTokens?: number },
377
+ ): Promise<{ agent: EditorAgent | undefined }> {
378
+ if (queuedUsers.length === 0) return { agent: undefined };
379
+ signal?.throwIfAborted();
380
+ const agents = this.ctx.get("agents") as EditorAgentRegistry | undefined;
381
+ if (agents === undefined) return { agent: undefined };
382
+ const existing = agents.get(sessionId);
383
+ if (existing !== undefined) {
384
+ await existing.whenIdle();
385
+ signal?.throwIfAborted();
386
+ // 清空 inbox 残留(rewind 将删除这些输入所属轮次的事件;残留消息若
387
+ // 不清空,agent 后续 splice 落库后无法从日志重放——inbox 增量投影
388
+ // 假设 log 只 append)。
389
+ if (existing.inboxPending) existing.clearInbox();
390
+ return { agent: existing };
391
+ }
392
+ // cold:resume 已持久化会话(create 会因「已存在持久化日志」失败)。
393
+ // resume 失败是硬错误:rewind 尚未发生,编辑保持原子(不截断不丢数据)。
394
+ const provider = headerConfig?.provider ?? "";
395
+ const model = headerConfig?.model ?? "";
396
+ if (provider.length === 0 || model.length === 0) {
397
+ // 兜底:从当前会话 events 解析(headerConfig 未提供时)。
398
+ const events = await this.readEvents(sessionId, signal);
399
+ const config = events.findLast((event) => event.type === "request/header")?.data.header
400
+ .config;
401
+ const fallbackProvider = config?.provider ?? "";
402
+ const fallbackModel = config?.model ?? "";
403
+ if (fallbackProvider.length === 0 || fallbackModel.length === 0) {
404
+ throw new SessionBranchError(
405
+ "无法重放:会话没有可解析的模型配置。",
406
+ "INVALID_BOUNDARY",
407
+ );
408
+ }
409
+ const handle = await agents.resume({
410
+ resumeSessionId: sessionId,
411
+ agentOptions: { provider: fallbackProvider, model: fallbackModel },
412
+ });
413
+ signal?.throwIfAborted();
414
+ return { agent: handle.agent };
415
+ }
416
+ const handle = await agents.resume({
417
+ resumeSessionId: sessionId,
418
+ agentOptions: { provider, model },
419
+ });
420
+ signal?.throwIfAborted();
421
+ return { agent: handle.agent };
422
+ }
423
+ }
424
+
425
+ export default SessionEditor;
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // HTTP 面(host):GET /session-editor(timeline 投影)/ POST /session-editor
429
+ // (edit | reroll | retry | rewind | fork)。
430
+ // ---------------------------------------------------------------------------
431
+
432
+ interface HttpRequestLike {
433
+ method?: string;
434
+ url?: string;
435
+ on(event: "data", listener: (chunk: Uint8Array | string) => void): this;
436
+ on(event: "end", listener: () => void): this;
437
+ on(event: "error", listener: (error: unknown) => void): this;
438
+ }
439
+
440
+ interface HttpResponseLike {
441
+ writeHead(status: number, headers?: Record<string, string>): unknown;
442
+ end(body?: string): void;
443
+ }
444
+
445
+ interface HttpServerLike {
446
+ register(route: {
447
+ kind: "exact";
448
+ path: string;
449
+ handler: (request: HttpRequestLike, response: HttpResponseLike) => void | Promise<void>;
450
+ }): () => void;
451
+ }
452
+
453
+ declare module "@deepseek-ai/cordis" {
454
+ interface Context {
455
+ webServer: HttpServerLike;
456
+ }
457
+ }
458
+
459
+ function objectValue(value: unknown): Record<string, unknown> {
460
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
461
+ throw new TypeError("请求体必须是 JSON 对象。");
462
+ }
463
+ return value as Record<string, unknown>;
464
+ }
465
+
466
+ function sessionIdOf(value: unknown): SessionId {
467
+ if (typeof value !== "string" || value.length === 0)
468
+ throw new TypeError("sessionId 必须是非空字符串。");
469
+ return value as SessionId;
470
+ }
471
+
472
+ function integerOf(value: unknown, name: string): number {
473
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
474
+ throw new TypeError(`${name} 必须是非负安全整数。`);
475
+ }
476
+ return value as number;
477
+ }
478
+
479
+ function cascadeOf(value: unknown): import("@morlay/session-branch").CascadePolicy {
480
+ if (value !== "truncate" && value !== "preserve")
481
+ throw new TypeError("cascade 必须是 truncate 或 preserve。");
482
+ return value;
483
+ }
484
+
485
+ function decodeOperation(value: unknown): SessionEditorOperation {
486
+ const record = objectValue(value);
487
+ const sessionId = sessionIdOf(record["sessionId"]);
488
+ switch (record["action"]) {
489
+ case "edit":
490
+ if (typeof record["text"] !== "string") throw new TypeError("text 必须是字符串。");
491
+ return {
492
+ action: "edit",
493
+ sessionId,
494
+ eventSeq: integerOf(record["eventSeq"], "eventSeq"),
495
+ blockIndex: integerOf(record["blockIndex"], "blockIndex"),
496
+ text: record["text"],
497
+ cascade: cascadeOf(record["cascade"]),
498
+ };
499
+ case "reroll":
500
+ return { action: "reroll", sessionId };
501
+ case "retry":
502
+ return {
503
+ action: "retry",
504
+ sessionId,
505
+ turn: integerOf(record["turn"], "turn"),
506
+ cascade: cascadeOf(record["cascade"]),
507
+ };
508
+ case "rewind":
509
+ return {
510
+ action: "rewind",
511
+ sessionId,
512
+ toBoundary: integerOf(record["toBoundary"], "toBoundary"),
513
+ };
514
+ case "fork":
515
+ return {
516
+ action: "fork",
517
+ sessionId,
518
+ ...(record["atSeq"] === undefined ? {} : { atSeq: integerOf(record["atSeq"], "atSeq") }),
519
+ ...(record["childSessionId"] === undefined
520
+ ? {}
521
+ : { childSessionId: sessionIdOf(record["childSessionId"]) }),
522
+ };
523
+ default:
524
+ throw new TypeError("action 必须是 edit、reroll、retry、rewind 或 fork。");
525
+ }
526
+ }
527
+
528
+ function requestJson(request: HttpRequestLike): Promise<unknown> {
529
+ return new Promise((resolve, reject) => {
530
+ const decoder = new TextDecoder();
531
+ let text = "";
532
+ request.on("data", (chunk) => {
533
+ text += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
534
+ });
535
+ request.on("end", () => {
536
+ try {
537
+ text += decoder.decode();
538
+ resolve(JSON.parse(text) as unknown);
539
+ } catch (error) {
540
+ reject(error);
541
+ }
542
+ });
543
+ request.on("error", reject);
544
+ });
545
+ }
546
+
547
+ function respondJson(response: HttpResponseLike, status: number, value: unknown): void {
548
+ response.writeHead(status, {
549
+ "content-type": "application/json; charset=utf-8",
550
+ "cache-control": "no-store",
551
+ });
552
+ response.end(JSON.stringify(value));
553
+ }
554
+
555
+ async function readTimeline(
556
+ editor: SessionEditor,
557
+ sessionId: SessionId,
558
+ ): Promise<SessionEditorTimeline> {
559
+ const timeline = await editor.timeline(sessionId);
560
+ const messages = await editor.editableMessages(sessionId);
561
+ const retryable = await editor.retryableTurns(sessionId);
562
+ return toTimelinePayload(sessionId, timeline, messages, retryable);
563
+ }
564
+
565
+ async function runOperation(
566
+ editor: SessionEditor,
567
+ operation: SessionEditorOperation,
568
+ ): Promise<SessionEditorOperationResult> {
569
+ switch (operation.action) {
570
+ case "edit": {
571
+ const result = await editor.edit(operation);
572
+ return {
573
+ sessionId: result.sessionId,
574
+ queuedTurns: result.queuedTurns,
575
+ ...(result.live === undefined ? {} : { live: result.live }),
576
+ };
577
+ }
578
+ case "reroll": {
579
+ const result = await editor.reroll(operation);
580
+ return {
581
+ sessionId: result.sessionId,
582
+ queuedTurns: result.queuedTurns,
583
+ ...(result.live === undefined ? {} : { live: result.live }),
584
+ };
585
+ }
586
+ case "retry": {
587
+ const result = await editor.retry(operation);
588
+ return {
589
+ sessionId: result.sessionId,
590
+ queuedTurns: result.queuedTurns,
591
+ ...(result.live === undefined ? {} : { live: result.live }),
592
+ };
593
+ }
594
+ case "rewind":
595
+ await editor.rewind(operation.sessionId, operation.toBoundary);
596
+ return { sessionId: operation.sessionId, queuedTurns: 0 };
597
+ case "fork":
598
+ return {
599
+ sessionId: await editor.fork(
600
+ operation.sessionId,
601
+ operation.atSeq,
602
+ operation.childSessionId,
603
+ ),
604
+ queuedTurns: 0,
605
+ };
606
+ }
607
+ }
608
+
609
+ async function handleRoute(
610
+ editor: SessionEditor,
611
+ request: HttpRequestLike,
612
+ response: HttpResponseLike,
613
+ ): Promise<void> {
614
+ try {
615
+ if (request.method === "GET") {
616
+ const url = new URL(request.url ?? SESSION_EDITOR_PATH, "http://session-editor.local");
617
+ const sessionId = sessionIdOf(url.searchParams.get("sessionId"));
618
+ respondJson(response, 200, await readTimeline(editor, sessionId));
619
+ return;
620
+ }
621
+ if (request.method === "POST") {
622
+ respondJson(
623
+ response,
624
+ 200,
625
+ await runOperation(editor, decodeOperation(await requestJson(request))),
626
+ );
627
+ return;
628
+ }
629
+ response.writeHead(405);
630
+ response.end();
631
+ } catch (error: unknown) {
632
+ const message = error instanceof Error ? error.message : String(error);
633
+ respondJson(response, error instanceof TypeError ? 400 : 409, {
634
+ error: message,
635
+ });
636
+ }
637
+ }
638
+
639
+ function registerHttpRoutes(ctx: Context): void {
640
+ const webServer = ctx.get("webServer") as HttpServerLike | undefined;
641
+ if (webServer === undefined) return;
642
+ ctx.effect(() => {
643
+ const editor = ctx.sessionEditor;
644
+ return webServer.register({
645
+ kind: "exact",
646
+ path: SESSION_EDITOR_PATH,
647
+ handler: (request, response) => handleRoute(editor, request, response),
648
+ });
649
+ }, "session-editor: HTTP route");
650
+ }
@@ -0,0 +1,13 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import type { InvariantInstaller } from "@deepseek-ai/dsh-invariants";
3
+
4
+ const PACKAGE_NAME = "@morlay/ui-conversation-message-actions";
5
+
6
+ export const name = "ui-conversation-message-actions-invariant";
7
+
8
+ export const inject = ["invariants"];
9
+
10
+ const install: InvariantInstaller = () => {};
11
+
12
+ export const apply = (ctx: Context): Promise<() => void> =>
13
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));