@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/plan.ts ADDED
@@ -0,0 +1,322 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { AssistantMessage, ContentBlock, UserMessage } from "@deepseek-ai/dsh-llm";
3
+ import type { SessionEvent, SessionId } from "@deepseek-ai/dsh-session";
4
+ import {
5
+ SESSION_BRANCH_VERSION_SCHEMA,
6
+ SessionBranchError,
7
+ type EditableBlockKind,
8
+ type SessionBranchEffect,
9
+ type SessionBranchVersionEvent,
10
+ } from "@morlay/session-branch";
11
+ import type {
12
+ EditOperation,
13
+ EditableMessageBlock,
14
+ RerollOperation,
15
+ RetryOperation,
16
+ RetryableTurn,
17
+ } from "./types.ts";
18
+
19
+ export interface ClosedTurn {
20
+ turn: number;
21
+ startSeq: number;
22
+
23
+ endSeq?: number;
24
+
25
+ closed: boolean;
26
+ user?: SessionEvent<"user/message">;
27
+ /** 轮内全部 user/message(agent 运行中 followup 追加的输入也计入)。 */
28
+ users: SessionEvent<"user/message">[];
29
+ assistants: SessionEvent<"assistant/message">[];
30
+ }
31
+
32
+ export interface OperationPlan {
33
+ anchorSeq: number;
34
+
35
+ rewindBoundary?: number;
36
+ version: SessionBranchVersionEvent;
37
+
38
+ manualTurn?: { turn: number; user: UserMessage; assistant: AssistantMessage };
39
+
40
+ queuedUsers: UserMessage[];
41
+ }
42
+
43
+ function isTextualBlock(
44
+ block: ContentBlock | undefined,
45
+ ): block is Extract<ContentBlock, { type: "text" | "reasoning" }> {
46
+ return block?.type === "text" || block?.type === "reasoning";
47
+ }
48
+
49
+ function userText(message: UserMessage): string {
50
+ return message.content
51
+ .filter((block): block is Extract<ContentBlock, { type: "text" }> => block.type === "text")
52
+ .map((block) => block.text)
53
+ .join("\n");
54
+ }
55
+
56
+ function cloneUser(
57
+ message: UserMessage,
58
+ content: ContentBlock[] = structuredClone(message.content),
59
+ ): UserMessage {
60
+ return Object.freeze({
61
+ id: randomUUID(),
62
+ role: "user",
63
+ content: Object.freeze(content),
64
+ source: Object.freeze({ kind: "user" }),
65
+ }) as UserMessage;
66
+ }
67
+
68
+ function replaceTextBlock(
69
+ content: readonly ContentBlock[],
70
+ blockIndex: number,
71
+ text: string,
72
+ ): ContentBlock[] {
73
+ const block = content[blockIndex];
74
+ if (!isTextualBlock(block))
75
+ throw new SessionBranchError("所选内容块不是可编辑文本。", "INVALID_BOUNDARY");
76
+ return content.map((candidate, index) =>
77
+ index === blockIndex ? ({ ...candidate, text } as ContentBlock) : structuredClone(candidate),
78
+ );
79
+ }
80
+
81
+ export function closedTurns(events: readonly SessionEvent[]): ClosedTurn[] {
82
+ const result: ClosedTurn[] = [];
83
+ let current: Omit<ClosedTurn, "endSeq" | "closed"> | undefined;
84
+ for (const event of events) {
85
+ if (event.type === "turn/start") {
86
+ current = { turn: event.data.turn, startSeq: event.seq, users: [], assistants: [] };
87
+ continue;
88
+ }
89
+ if (current === undefined) continue;
90
+ if (event.type === "user/message" && event.data.source.kind === "user") {
91
+ // 轮内全部 user/message 都记录:agent 运行中 followup 追加的输入
92
+ // (第二条及之后)也是已落定文本,可编辑。
93
+ if (current.user === undefined) current.user = event;
94
+ current.users.push(event);
95
+ continue;
96
+ }
97
+ if (event.type === "assistant/message" && event.data.turn === current.turn) {
98
+ current.assistants.push(event);
99
+ continue;
100
+ }
101
+ if (event.type === "turn/end" && event.data.turn === current.turn) {
102
+ result.push({ ...current, endSeq: event.seq, closed: true });
103
+ current = undefined;
104
+ }
105
+ }
106
+ // 未闭合轮次(无 turn/end)也保留:user 消息已落定,编辑时 rewind 到该
107
+ // 消息(exclusive drop)重放即可。
108
+ if (current !== undefined) result.push({ ...current, closed: false });
109
+ return result;
110
+ }
111
+
112
+ export function editableMessages(turns: readonly ClosedTurn[]): EditableMessageBlock[] {
113
+ const result: EditableMessageBlock[] = [];
114
+ for (const turn of turns) {
115
+ for (const user of turn.users) {
116
+ for (const [blockIndex, block] of user.data.content.entries()) {
117
+ if (block.type !== "text") continue;
118
+ result.push({
119
+ key: `${String(user.seq)}:${String(blockIndex)}`,
120
+ turn: turn.turn,
121
+ eventSeq: user.seq,
122
+ blockIndex,
123
+ kind: "user",
124
+ text: block.text,
125
+ time: user.time,
126
+ });
127
+ }
128
+ }
129
+ // 未闭合轮次的助手消息是流式 partial,不可编辑。
130
+ if (!turn.closed) continue;
131
+ for (const event of turn.assistants) {
132
+ for (const [blockIndex, block] of event.data.message.content.entries()) {
133
+ if (!isTextualBlock(block)) continue;
134
+ result.push({
135
+ key: `${String(event.seq)}:${String(blockIndex)}`,
136
+ turn: turn.turn,
137
+ eventSeq: event.seq,
138
+ blockIndex,
139
+ kind: block.type === "reasoning" ? "assistant.reasoning" : "assistant.response",
140
+ text: block.text,
141
+ time: event.time,
142
+ });
143
+ }
144
+ }
145
+ }
146
+ return result;
147
+ }
148
+
149
+ export function retryableTurns(turns: readonly ClosedTurn[]): RetryableTurn[] {
150
+ return turns.flatMap((turn): RetryableTurn[] =>
151
+ // 未闭合轮次无已落定回复可重生成,不可重试。
152
+ turn.user === undefined || !turn.closed
153
+ ? []
154
+ : [
155
+ {
156
+ turn: turn.turn,
157
+ userEventSeq: turn.user.seq,
158
+ preview: userText(turn.user.data),
159
+ time: turn.user.time,
160
+ },
161
+ ],
162
+ );
163
+ }
164
+
165
+ export function downstreamUsers(turns: readonly ClosedTurn[], start: number): UserMessage[] {
166
+ return turns
167
+ .slice(start)
168
+ .flatMap((turn): UserMessage[] => turn.users.map((user) => cloneUser(user.data)));
169
+ }
170
+
171
+ function assistantReplacement(
172
+ event: SessionEvent<"assistant/message">,
173
+ blockIndex: number,
174
+ text: string,
175
+ ): AssistantMessage {
176
+ const replaced = replaceTextBlock(event.data.message.content, blockIndex, text).filter(
177
+ (block) => block.type === "text" || block.type === "reasoning",
178
+ );
179
+ return Object.freeze({
180
+ id: randomUUID(),
181
+ role: "assistant",
182
+ content: Object.freeze(replaced),
183
+ source: Object.freeze({
184
+ kind: "model",
185
+ provider: event.data.message.source.provider,
186
+ model: event.data.message.source.model,
187
+ }),
188
+ }) as AssistantMessage;
189
+ }
190
+
191
+ function pairVersionEffect(
192
+ sourceSessionId: SessionId,
193
+ effect: Omit<SessionBranchEffect, "id">,
194
+ ): SessionBranchVersionEvent {
195
+ return {
196
+ schemaVersion: SESSION_BRANCH_VERSION_SCHEMA,
197
+ effect: { ...effect, id: randomUUID() },
198
+ inverse: { kind: "restore-version", sessionId: sourceSessionId },
199
+ };
200
+ }
201
+
202
+ export function editPlan(operation: EditOperation, turns: readonly ClosedTurn[]): OperationPlan {
203
+ const turnIndex = turns.findIndex(
204
+ (turn) =>
205
+ operation.eventSeq > turn.startSeq &&
206
+ (turn.endSeq === undefined || operation.eventSeq < turn.endSeq),
207
+ );
208
+ const turn = turns[turnIndex];
209
+ if (turn === undefined)
210
+ throw new SessionBranchError("所选消息不属于已落定回合。", "INVALID_BOUNDARY");
211
+ const event =
212
+ turn.users.find((candidate) => candidate.seq === operation.eventSeq) ??
213
+ turn.assistants.find((candidate) => candidate.seq === operation.eventSeq);
214
+ if (event === undefined)
215
+ throw new SessionBranchError("所选消息不存在或不可编辑。", "INVALID_BOUNDARY");
216
+
217
+ if (event.type === "user/message") {
218
+ const before = event.data.content[operation.blockIndex];
219
+ if (before?.type !== "text")
220
+ throw new SessionBranchError("所选用户消息块不是文本。", "INVALID_BOUNDARY");
221
+ const edited = cloneUser(
222
+ event.data,
223
+ replaceTextBlock(event.data.content, operation.blockIndex, operation.text),
224
+ );
225
+ // 轮内后续 user/message(agent 运行中 followup 追加的输入)是已落定
226
+ // 输入:rewind 会 drop 它们,重放时保留(truncate 只截断回复,不丢输入)。
227
+ const userIndex = turn.users.findIndex((candidate) => candidate.seq === event.seq);
228
+ const sameTurnFollowups = turn.users.slice(userIndex + 1).map((user) => cloneUser(user.data));
229
+ const later = operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : [];
230
+ return {
231
+ anchorSeq: turn.startSeq,
232
+ // 轮首 user 编辑(闭合轮):整轮截断重放(rewind 到前一轮 turn/end);
233
+ // 其余情况(未闭合轮、轮内 followup):rewind 到该消息本身
234
+ // (exclusive drop),保留轮内已落定的前置输入与回复。
235
+ ...(turn.closed && userIndex === 0 ? {} : { rewindBoundary: event.seq }),
236
+ version: pairVersionEffect(operation.sessionId, {
237
+ operation: "edit",
238
+ cascade: operation.cascade,
239
+ targetTurn: turn.turn,
240
+ targetEventSeq: event.seq,
241
+ targetBlockIndex: operation.blockIndex,
242
+ blockKind: "user",
243
+ before: before.text,
244
+ after: operation.text,
245
+ }),
246
+ queuedUsers: [edited, ...sameTurnFollowups, ...later],
247
+ };
248
+ }
249
+
250
+ // 未闭合轮次的助手消息是流式 partial,无最终内容可编辑。
251
+ if (!turn.closed)
252
+ throw new SessionBranchError("未闭合轮次的助手消息不可编辑。", "INVALID_BOUNDARY");
253
+ const before = event.data.message.content[operation.blockIndex];
254
+ if (!isTextualBlock(before))
255
+ throw new SessionBranchError("所选助手消息块不是文本或思考。", "INVALID_BOUNDARY");
256
+ const blockKind: EditableBlockKind =
257
+ before.type === "reasoning" ? "assistant.reasoning" : "assistant.response";
258
+ if (turn.user === undefined)
259
+ throw new SessionBranchError("所选助手消息没有可重建的用户输入。", "INVALID_BOUNDARY");
260
+ return {
261
+ anchorSeq: turn.startSeq,
262
+ version: pairVersionEffect(operation.sessionId, {
263
+ operation: "edit",
264
+ cascade: operation.cascade,
265
+ targetTurn: turn.turn,
266
+ targetEventSeq: event.seq,
267
+ targetBlockIndex: operation.blockIndex,
268
+ blockKind,
269
+ before: before.text,
270
+ after: operation.text,
271
+ }),
272
+ manualTurn: {
273
+ turn: turn.turn,
274
+ user: cloneUser(turn.user.data),
275
+ assistant: assistantReplacement(event, operation.blockIndex, operation.text),
276
+ },
277
+ queuedUsers: operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : [],
278
+ };
279
+ }
280
+
281
+ export function retryPlan(operation: RetryOperation, turns: readonly ClosedTurn[]): OperationPlan {
282
+ const turnIndex = turns.findIndex((turn) => turn.turn === operation.turn);
283
+ const turn = turns[turnIndex];
284
+ if (turn?.user === undefined)
285
+ throw new SessionBranchError("所选回合没有可重放的用户输入。", "INVALID_BOUNDARY");
286
+ return {
287
+ anchorSeq: turn.startSeq,
288
+ version: pairVersionEffect(operation.sessionId, {
289
+ operation: "retry",
290
+ cascade: operation.cascade,
291
+ targetTurn: turn.turn,
292
+ targetEventSeq: turn.user.seq,
293
+ }),
294
+ queuedUsers:
295
+ operation.cascade === "preserve"
296
+ ? downstreamUsers(turns, turnIndex)
297
+ : turn.users.map((user) => cloneUser(user.data)),
298
+ };
299
+ }
300
+
301
+ export function rerollPlan(operation: RerollOperation, turns: readonly ClosedTurn[]): OperationPlan {
302
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
303
+ const turn = turns[index];
304
+ // 未闭合轮次无已落定的助手回复可重生成。
305
+ if (turn?.user === undefined || !turn.closed) continue;
306
+ const target = turn.assistants.findLast((event) =>
307
+ event.data.message.content.some(isTextualBlock),
308
+ );
309
+ if (target === undefined) continue;
310
+ return {
311
+ anchorSeq: turn.startSeq,
312
+ version: pairVersionEffect(operation.sessionId, {
313
+ operation: "reroll",
314
+ cascade: "truncate",
315
+ targetTurn: turn.turn,
316
+ targetEventSeq: target.seq,
317
+ }),
318
+ queuedUsers: turn.users.map((user) => cloneUser(user.data)),
319
+ };
320
+ }
321
+ throw new SessionBranchError("当前会话没有可重生成的已落定助手回复。", "INVALID_BOUNDARY");
322
+ }
package/src/shared.ts ADDED
@@ -0,0 +1,167 @@
1
+ import type { SessionId } from "@deepseek-ai/dsh-session";
2
+ import type { BranchTimeline, CascadePolicy } from "@morlay/session-branch";
3
+ import type { VersionOperation } from "@morlay/session-branch";
4
+
5
+ export const SESSION_EDITOR_PATH = "/session-editor";
6
+
7
+ export type { VersionOperation } from "@morlay/session-branch";
8
+
9
+ export interface EditOperation {
10
+ action: "edit";
11
+ sessionId: SessionId;
12
+ eventSeq: number;
13
+ blockIndex: number;
14
+ text: string;
15
+ cascade: CascadePolicy;
16
+ }
17
+
18
+ export interface RerollOperation {
19
+ action: "reroll";
20
+ sessionId: SessionId;
21
+ }
22
+
23
+ export interface RetryOperation {
24
+ action: "retry";
25
+ sessionId: SessionId;
26
+ turn: number;
27
+ cascade: CascadePolicy;
28
+ }
29
+
30
+ export interface RewindOperation {
31
+ action: "rewind";
32
+ sessionId: SessionId;
33
+ toBoundary: number;
34
+ }
35
+
36
+ export interface ForkOperation {
37
+ action: "fork";
38
+ sessionId: SessionId;
39
+ atSeq?: number;
40
+ childSessionId?: SessionId;
41
+ }
42
+
43
+ export type SessionEditorOperation =
44
+ | EditOperation
45
+ | RerollOperation
46
+ | RetryOperation
47
+ | RewindOperation
48
+ | ForkOperation;
49
+
50
+ export interface SessionEditorOperationResult {
51
+ sessionId: SessionId;
52
+ queuedTurns: number;
53
+
54
+ live?: boolean;
55
+ }
56
+
57
+ export interface EditableMessageBlock {
58
+ key: string;
59
+ turn: number;
60
+ eventSeq: number;
61
+ blockIndex: number;
62
+ kind: "user" | "assistant.reasoning" | "assistant.response";
63
+ text: string;
64
+ time: number;
65
+ }
66
+
67
+ export interface RetryableTurn {
68
+ turn: number;
69
+ userEventSeq: number;
70
+ preview: string;
71
+ time: number;
72
+ }
73
+
74
+ export interface VersionSummary {
75
+ sessionId: string;
76
+ parentSessionId?: string;
77
+ effectId?: string;
78
+ inverseSessionId?: string;
79
+ createdAt: number;
80
+ depth: number;
81
+ current: boolean;
82
+ onCurrentEffectPath: boolean;
83
+ operation?: VersionOperation;
84
+ cascade?: CascadePolicy;
85
+ targetTurn?: number;
86
+ blockKind?: EditableMessageBlock["kind"];
87
+ before?: string;
88
+ after?: string;
89
+ }
90
+
91
+ export interface SessionEditorTimeline {
92
+ sessionId: string;
93
+ messages: EditableMessageBlock[];
94
+ retryableTurns: RetryableTurn[];
95
+ versions: VersionSummary[];
96
+
97
+ undoStack: string[];
98
+
99
+ redoSessionIds: string[];
100
+ }
101
+
102
+ export function toTimelinePayload(
103
+ sessionId: SessionId,
104
+ timeline: BranchTimeline,
105
+ messages: EditableMessageBlock[],
106
+ retryableTurns: RetryableTurn[],
107
+ ): SessionEditorTimeline {
108
+ const currentPath = new Set<string>();
109
+ for (const node of timeline.nodes) currentPath.add(String(node.sessionId));
110
+ const versions: VersionSummary[] = timeline.nodes.map((node) => ({
111
+ sessionId: String(node.sessionId),
112
+ ...(node.parentSessionId === undefined
113
+ ? {}
114
+ : { parentSessionId: String(node.parentSessionId) }),
115
+ ...(node.effect === undefined
116
+ ? {}
117
+ : {
118
+ effectId: node.effect.id,
119
+ inverseSessionId: String(node.inverseSessionId),
120
+ operation: node.effect.operation,
121
+ cascade: node.effect.cascade,
122
+ targetTurn: node.effect.targetTurn,
123
+ ...(node.effect.blockKind === undefined ? {} : { blockKind: node.effect.blockKind }),
124
+ ...(node.effect.before === undefined ? {} : { before: node.effect.before }),
125
+ ...(node.effect.after === undefined ? {} : { after: node.effect.after }),
126
+ }),
127
+ createdAt: node.createdAt,
128
+ depth: depthOf(timeline, node.sessionId),
129
+ current: String(node.sessionId) === String(sessionId),
130
+ onCurrentEffectPath: currentPath.has(String(node.sessionId)),
131
+ }));
132
+ const versionsById = new Map(versions.map((version) => [version.sessionId, version]));
133
+ const undoStack: string[] = [];
134
+ let cursor = versionsById.get(String(sessionId));
135
+ while (cursor?.inverseSessionId !== undefined) {
136
+ if (undoStack.includes(cursor.inverseSessionId)) break;
137
+ undoStack.push(cursor.inverseSessionId);
138
+ cursor = versionsById.get(cursor.inverseSessionId);
139
+ }
140
+ const redoSessionIds = versions
141
+ .filter((version) => version.inverseSessionId === String(sessionId))
142
+ .map((version) => version.sessionId);
143
+ return {
144
+ sessionId: String(sessionId),
145
+ messages,
146
+ retryableTurns,
147
+ versions,
148
+ undoStack,
149
+ redoSessionIds,
150
+ };
151
+ }
152
+
153
+ function depthOf(
154
+ timeline: BranchTimeline,
155
+ sessionId: import("@deepseek-ai/dsh-session").SessionId,
156
+ ): number {
157
+ const byId = new Map(timeline.nodes.map((node) => [String(node.sessionId), node]));
158
+ let depth = 0;
159
+ let cursor = byId.get(String(sessionId));
160
+ const seen = new Set<string>();
161
+ while (cursor?.parentSessionId !== undefined && !seen.has(String(cursor.sessionId))) {
162
+ seen.add(String(cursor.sessionId));
163
+ depth += 1;
164
+ cursor = byId.get(String(cursor.parentSessionId));
165
+ }
166
+ return depth;
167
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,151 @@
1
+ import { Context } from "@deepseek-ai/cordis";
2
+ import { TokenMeter } from "@deepseek-ai/dsh-token-meter";
3
+ import {
4
+ Session,
5
+ SessionId as SessionIdBrand,
6
+ SessionSeq,
7
+ SessionStore,
8
+ type SessionEvent,
9
+ type SessionHeader,
10
+ } from "@deepseek-ai/dsh-session";
11
+ import SessionProjectionRegistry from "@deepseek-ai/dsh-session-projection";
12
+ import { type BranchTimeline } from "@morlay/session-branch";
13
+ import SessionPersistenceSqlite from "@morlay/session-rdb";
14
+ import { parseJsonlArtifact } from "@morlay/session-rdb/artifact";
15
+ import { EmptySettings, meta, oneTurnLog } from "@morlay/session-rdb/testing";
16
+ import { SessionEditor } from "@morlay/ui-conversation-message-actions";
17
+
18
+ export {
19
+ BranchTimeline,
20
+ Session,
21
+ SessionIdBrand,
22
+ SessionSeq,
23
+ SessionStore,
24
+ TokenMeter,
25
+ EmptySettings,
26
+ meta,
27
+ oneTurnLog,
28
+ parseJsonlArtifact,
29
+ };
30
+ export type { SessionEvent, SessionHeader };
31
+
32
+ export interface Harness {
33
+ ctx: Context;
34
+ editor: SessionEditor;
35
+ dispose: () => Promise<void>;
36
+ }
37
+
38
+ export async function harness(): Promise<Harness> {
39
+ const ctx = new Context();
40
+ await ctx.plugin(EmptySettings);
41
+ await ctx.plugin(SessionStore);
42
+ new SessionProjectionRegistry(ctx);
43
+ const fiber = await ctx.plugin(SessionPersistenceSqlite, { type: "sqlite", path: ":memory:" });
44
+ await ctx.plugin(SessionEditor);
45
+ return { ctx, editor: ctx.sessionEditor, dispose: () => fiber.dispose() };
46
+ }
47
+
48
+ export function twoTurnLog(): SessionEvent[] {
49
+ const first = oneTurnLog();
50
+ const second: SessionEvent[] = oneTurnLog().map(
51
+ (event) =>
52
+ ({
53
+ ...event,
54
+ seq: event.seq + 6,
55
+ time: event.time + 100,
56
+ data: { ...event.data, turn: 2 },
57
+ }) as SessionEvent,
58
+ );
59
+ return [...first, ...second];
60
+ }
61
+
62
+ export async function createPersisted(
63
+ ctx: Context,
64
+ id: string,
65
+ events: readonly SessionEvent[],
66
+ header: SessionHeader = meta(id),
67
+ ): Promise<void> {
68
+ await ctx.sessionPersistence.create(header);
69
+ await ctx.sessionPersistence.append(SessionIdBrand(id), [...events]);
70
+ }
71
+
72
+ /** 构造一条 user/message 事件。 */
73
+ export function userMessage(
74
+ seq: number,
75
+ id: string,
76
+ text: string,
77
+ time = seq,
78
+ ): SessionEvent {
79
+ return {
80
+ type: "user/message",
81
+ seq: SessionSeq(seq),
82
+ time,
83
+ data: {
84
+ id,
85
+ role: "user",
86
+ content: [{ type: "text", text }],
87
+ source: { kind: "user" },
88
+ },
89
+ surfaceOp: "append",
90
+ } as SessionEvent;
91
+ }
92
+
93
+ /** 构造一条 assistant/message 事件(纯文本回复)。 */
94
+ export function assistantMessage(
95
+ seq: number,
96
+ turn: number,
97
+ step: number,
98
+ id: string,
99
+ text: string,
100
+ time = seq,
101
+ ): SessionEvent {
102
+ return {
103
+ type: "assistant/message",
104
+ seq: SessionSeq(seq),
105
+ time,
106
+ data: {
107
+ turn,
108
+ step,
109
+ message: {
110
+ id,
111
+ role: "assistant",
112
+ content: [{ type: "text", text }],
113
+ source: { kind: "model", provider: "mock", model: "mock" },
114
+ },
115
+ },
116
+ surfaceOp: "append",
117
+ } as SessionEvent;
118
+ }
119
+
120
+ /** 真实 agent-loop 形状的一轮:轮首输入 + 可选 followup,可闭合。 */
121
+ export function turnLog(
122
+ base: number,
123
+ turn: number,
124
+ opts: { users?: Array<{ id: string; text: string }>; closed?: boolean; time?: number } = {},
125
+ ): SessionEvent[] {
126
+ const { closed = true, time = base } = opts;
127
+ const users = opts.users ?? [{ id: `t${turn}-u1`, text: `turn ${turn} input` }];
128
+ const events: SessionEvent[] = [
129
+ { type: "turn/start", seq: SessionSeq(base), time, data: { turn } },
130
+ ];
131
+ let seq = base + 1;
132
+ let step = 1;
133
+ for (const user of users) {
134
+ events.push({ type: "step/start", seq: SessionSeq(seq++), time, data: { turn, step } });
135
+ events.push(userMessage(seq++, user.id, user.text, time));
136
+ events.push(
137
+ assistantMessage(seq++, turn, step, `${user.id}-a`, `answer to ${user.text}`, time),
138
+ );
139
+ events.push({ type: "step/end", seq: SessionSeq(seq++), time, data: { turn, step } });
140
+ step += 1;
141
+ }
142
+ if (closed) {
143
+ events.push({
144
+ type: "turn/end",
145
+ seq: SessionSeq(seq++),
146
+ time,
147
+ data: { turn, reason: { kind: "completed" } },
148
+ });
149
+ }
150
+ return events;
151
+ }
package/src/types.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { SessionId } from "@deepseek-ai/dsh-session";
2
+ import type { BranchTimeline, CascadePolicy } from "@morlay/session-branch";
3
+
4
+ export interface EditOperation {
5
+ action: "edit";
6
+ sessionId: SessionId;
7
+
8
+ eventSeq: number;
9
+
10
+ blockIndex: number;
11
+
12
+ text: string;
13
+ cascade: CascadePolicy;
14
+ }
15
+
16
+ export interface RerollOperation {
17
+ action: "reroll";
18
+ sessionId: SessionId;
19
+ }
20
+
21
+ export interface RetryOperation {
22
+ action: "retry";
23
+ sessionId: SessionId;
24
+ turn: number;
25
+ cascade: CascadePolicy;
26
+ }
27
+
28
+ export interface RewindOperation {
29
+ action: "rewind";
30
+ sessionId: SessionId;
31
+ toBoundary: number;
32
+ }
33
+
34
+ export interface ForkOperation {
35
+ action: "fork";
36
+ sessionId: SessionId;
37
+ atSeq?: number;
38
+ childSessionId?: SessionId;
39
+ }
40
+
41
+ export type SessionEditorOperation =
42
+ | EditOperation
43
+ | RerollOperation
44
+ | RetryOperation
45
+ | RewindOperation
46
+ | ForkOperation;
47
+
48
+ export interface SessionEditorResult {
49
+ sessionId: SessionId;
50
+
51
+ queuedTurns: number;
52
+
53
+ live?: boolean;
54
+ }
55
+
56
+ export type SessionEditorTimeline = BranchTimeline;
57
+
58
+ export interface EditableMessageBlock {
59
+ key: string;
60
+ turn: number;
61
+ eventSeq: number;
62
+ blockIndex: number;
63
+ kind: "user" | "assistant.reasoning" | "assistant.response";
64
+ text: string;
65
+ time: number;
66
+ }
67
+
68
+ export interface RetryableTurn {
69
+ turn: number;
70
+ userEventSeq: number;
71
+ preview: string;
72
+ time: number;
73
+ }