@morlay/ui-conversation-message-actions 0.0.12 → 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.
- package/dist/client.js +23 -23
- package/dist/index.d.mts +21 -69
- package/dist/index.mjs +2 -606
- package/dist/invariant.d.mts +1 -2
- package/dist/invariant.mjs +0 -2
- package/dist/plan-Bw4zoI4N.d.mts +86 -0
- package/dist/plan.d.mts +2 -0
- package/dist/plan.mjs +225 -0
- package/dist/src-CXYW0Bc0.mjs +408 -0
- package/dist/testing.d.mts +707 -0
- package/dist/testing.mjs +23690 -0
- package/package.json +32 -12
- package/src/index.ts +79 -365
- package/src/plan.ts +322 -0
- package/src/testing.ts +151 -0
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/invariant.d.mts.map +0 -1
- package/dist/invariant.mjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morlay/ui-conversation-message-actions",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "Rewind / retry / fork orchestration (host) plus conversation.chat.node renderer replacement (browser) — message edit / retry actions on user bubbles.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"conversation-chat-node",
|
|
@@ -26,22 +26,19 @@
|
|
|
26
26
|
],
|
|
27
27
|
"type": "module",
|
|
28
28
|
"exports": {
|
|
29
|
-
".":
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"./client": "./dist/client.js",
|
|
34
|
-
"./invariant": {
|
|
35
|
-
"types": "./dist/invariant.d.mts",
|
|
36
|
-
"default": "./dist/invariant.mjs"
|
|
37
|
-
},
|
|
29
|
+
".": "./dist/index.mjs",
|
|
30
|
+
"./invariant": "./dist/invariant.mjs",
|
|
31
|
+
"./plan": "./dist/plan.mjs",
|
|
32
|
+
"./testing": "./dist/testing.mjs",
|
|
38
33
|
"./package.json": "./package.json",
|
|
39
|
-
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
34
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
35
|
+
"./client": "./dist/client.js"
|
|
40
36
|
},
|
|
41
37
|
"dependencies": {
|
|
42
|
-
"@morlay/session-branch": "^0.0.
|
|
38
|
+
"@morlay/session-branch": "^0.0.3"
|
|
43
39
|
},
|
|
44
40
|
"devDependencies": {
|
|
41
|
+
"@morlay/session-rdb": "^0.0.13",
|
|
45
42
|
"@types/react": "^19.2.18",
|
|
46
43
|
"@types/react-dom": "^19.2.5",
|
|
47
44
|
"lightningcss": "^1.32.0",
|
|
@@ -81,6 +78,29 @@
|
|
|
81
78
|
]
|
|
82
79
|
}
|
|
83
80
|
},
|
|
81
|
+
"inlinedDependencies": {
|
|
82
|
+
"@standard-schema/spec": "1.1.0",
|
|
83
|
+
"@vitest/pretty-format": "4.1.11",
|
|
84
|
+
"@vitest/runner": "4.1.11",
|
|
85
|
+
"@vitest/utils": "4.1.11",
|
|
86
|
+
"drizzle-orm": "1.0.0-rc.5-ab785fc",
|
|
87
|
+
"fflate": "0.8.3",
|
|
88
|
+
"pg": "8.23.0",
|
|
89
|
+
"pg-cloudflare": "1.4.0",
|
|
90
|
+
"pg-connection-string": "2.14.0",
|
|
91
|
+
"pg-int8": "1.0.1",
|
|
92
|
+
"pg-pool": "3.14.0",
|
|
93
|
+
"pg-protocol": "1.16.0",
|
|
94
|
+
"pg-types": "2.2.0",
|
|
95
|
+
"pgpass": "1.0.5",
|
|
96
|
+
"postgres-array": "2.0.0",
|
|
97
|
+
"postgres-bytea": "1.0.1",
|
|
98
|
+
"postgres-date": "1.0.7",
|
|
99
|
+
"postgres-interval": "1.2.0",
|
|
100
|
+
"split2": "4.2.0",
|
|
101
|
+
"tinyrainbow": "3.1.1",
|
|
102
|
+
"xtend": "4.0.2"
|
|
103
|
+
},
|
|
84
104
|
"scripts": {
|
|
85
105
|
"build": "pnpm exec tsdown"
|
|
86
106
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Service, type Context } from "@deepseek-ai/cordis";
|
|
2
|
-
import type { AssistantMessage,
|
|
2
|
+
import type { AssistantMessage, UserMessage } from "@deepseek-ai/dsh-llm";
|
|
3
3
|
import type {
|
|
4
4
|
Session,
|
|
5
5
|
SessionEvent,
|
|
@@ -7,16 +7,11 @@ import type {
|
|
|
7
7
|
SurfaceEventType,
|
|
8
8
|
SurfaceIntent,
|
|
9
9
|
} from "@deepseek-ai/dsh-session";
|
|
10
|
-
import { randomUUID } from "node:crypto";
|
|
11
10
|
import {
|
|
12
|
-
SESSION_BRANCH_VERSION_SCHEMA,
|
|
13
11
|
SessionBranchError,
|
|
14
12
|
balanceRewindPrefix,
|
|
15
13
|
type BranchBoundary,
|
|
16
14
|
type BranchTimeline,
|
|
17
|
-
type EditableBlockKind,
|
|
18
|
-
type SessionBranchEffect,
|
|
19
|
-
type SessionBranchVersionEvent,
|
|
20
15
|
} from "@morlay/session-branch";
|
|
21
16
|
import type {
|
|
22
17
|
EditOperation,
|
|
@@ -33,6 +28,14 @@ import {
|
|
|
33
28
|
type SessionEditorTimeline,
|
|
34
29
|
toTimelinePayload,
|
|
35
30
|
} from "./shared.ts";
|
|
31
|
+
import {
|
|
32
|
+
closedTurns,
|
|
33
|
+
editableMessages,
|
|
34
|
+
editPlan,
|
|
35
|
+
retryPlan,
|
|
36
|
+
rerollPlan,
|
|
37
|
+
retryableTurns,
|
|
38
|
+
} from "./plan.ts";
|
|
36
39
|
|
|
37
40
|
export type { CascadePolicy, EditableBlockKind } from "@morlay/session-branch";
|
|
38
41
|
export type {
|
|
@@ -60,6 +63,16 @@ declare module "@deepseek-ai/cordis" {
|
|
|
60
63
|
export interface EditorAgent {
|
|
61
64
|
readonly session: Session;
|
|
62
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;
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
export interface EditorAgentHandle {
|
|
@@ -87,306 +100,6 @@ export interface EditorAgentRegistry {
|
|
|
87
100
|
}): Promise<EditorAgentHandle>;
|
|
88
101
|
}
|
|
89
102
|
|
|
90
|
-
interface ClosedTurn {
|
|
91
|
-
turn: number;
|
|
92
|
-
startSeq: number;
|
|
93
|
-
|
|
94
|
-
endSeq?: number;
|
|
95
|
-
|
|
96
|
-
closed: boolean;
|
|
97
|
-
user?: SessionEvent<"user/message">;
|
|
98
|
-
assistants: SessionEvent<"assistant/message">[];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function closedTurns(events: readonly SessionEvent[]): ClosedTurn[] {
|
|
102
|
-
const result: ClosedTurn[] = [];
|
|
103
|
-
let current: Omit<ClosedTurn, "endSeq" | "closed"> | undefined;
|
|
104
|
-
for (const event of events) {
|
|
105
|
-
if (event.type === "turn/start") {
|
|
106
|
-
current = { turn: event.data.turn, startSeq: event.seq, assistants: [] };
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
if (current === undefined) continue;
|
|
110
|
-
if (
|
|
111
|
-
event.type === "user/message" &&
|
|
112
|
-
current.user === undefined &&
|
|
113
|
-
event.data.source.kind === "user"
|
|
114
|
-
) {
|
|
115
|
-
current.user = event;
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
if (event.type === "assistant/message" && event.data.turn === current.turn) {
|
|
119
|
-
current.assistants.push(event);
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
if (event.type === "turn/end" && event.data.turn === current.turn) {
|
|
123
|
-
result.push({ ...current, endSeq: event.seq, closed: true });
|
|
124
|
-
current = undefined;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
// 未闭合轮次(无 turn/end)也保留:user 消息已落定,编辑时 rewind 到该
|
|
128
|
-
// 消息(exclusive drop)重放即可。
|
|
129
|
-
if (current !== undefined) result.push({ ...current, closed: false });
|
|
130
|
-
return result;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function isTextualBlock(
|
|
134
|
-
block: ContentBlock | undefined,
|
|
135
|
-
): block is Extract<ContentBlock, { type: "text" | "reasoning" }> {
|
|
136
|
-
return block?.type === "text" || block?.type === "reasoning";
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function userText(message: UserMessage): string {
|
|
140
|
-
return message.content
|
|
141
|
-
.filter((block): block is Extract<ContentBlock, { type: "text" }> => block.type === "text")
|
|
142
|
-
.map((block) => block.text)
|
|
143
|
-
.join("\n");
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function cloneUser(
|
|
147
|
-
message: UserMessage,
|
|
148
|
-
content: ContentBlock[] = structuredClone(message.content),
|
|
149
|
-
): UserMessage {
|
|
150
|
-
return Object.freeze({
|
|
151
|
-
id: randomUUID(),
|
|
152
|
-
role: "user",
|
|
153
|
-
content: Object.freeze(content),
|
|
154
|
-
source: Object.freeze({ kind: "user" }),
|
|
155
|
-
}) as UserMessage;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function replaceTextBlock(
|
|
159
|
-
content: readonly ContentBlock[],
|
|
160
|
-
blockIndex: number,
|
|
161
|
-
text: string,
|
|
162
|
-
): ContentBlock[] {
|
|
163
|
-
const block = content[blockIndex];
|
|
164
|
-
if (!isTextualBlock(block))
|
|
165
|
-
throw new SessionBranchError("所选内容块不是可编辑文本。", "INVALID_BOUNDARY");
|
|
166
|
-
return content.map((candidate, index) =>
|
|
167
|
-
index === blockIndex ? ({ ...candidate, text } as ContentBlock) : structuredClone(candidate),
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function editableMessages(turns: readonly ClosedTurn[]): EditableMessageBlock[] {
|
|
172
|
-
const result: EditableMessageBlock[] = [];
|
|
173
|
-
for (const turn of turns) {
|
|
174
|
-
if (turn.user !== undefined) {
|
|
175
|
-
for (const [blockIndex, block] of turn.user.data.content.entries()) {
|
|
176
|
-
if (block.type !== "text") continue;
|
|
177
|
-
result.push({
|
|
178
|
-
key: `${String(turn.user.seq)}:${String(blockIndex)}`,
|
|
179
|
-
turn: turn.turn,
|
|
180
|
-
eventSeq: turn.user.seq,
|
|
181
|
-
blockIndex,
|
|
182
|
-
kind: "user",
|
|
183
|
-
text: block.text,
|
|
184
|
-
time: turn.user.time,
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
// 未闭合轮次的助手消息是流式 partial,不可编辑。
|
|
189
|
-
if (!turn.closed) continue;
|
|
190
|
-
for (const event of turn.assistants) {
|
|
191
|
-
for (const [blockIndex, block] of event.data.message.content.entries()) {
|
|
192
|
-
if (!isTextualBlock(block)) continue;
|
|
193
|
-
result.push({
|
|
194
|
-
key: `${String(event.seq)}:${String(blockIndex)}`,
|
|
195
|
-
turn: turn.turn,
|
|
196
|
-
eventSeq: event.seq,
|
|
197
|
-
blockIndex,
|
|
198
|
-
kind: block.type === "reasoning" ? "assistant.reasoning" : "assistant.response",
|
|
199
|
-
text: block.text,
|
|
200
|
-
time: event.time,
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return result;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function retryableTurns(turns: readonly ClosedTurn[]): RetryableTurn[] {
|
|
209
|
-
return turns.flatMap((turn): RetryableTurn[] =>
|
|
210
|
-
// 未闭合轮次无已落定回复可重生成,不可重试。
|
|
211
|
-
turn.user === undefined || !turn.closed
|
|
212
|
-
? []
|
|
213
|
-
: [
|
|
214
|
-
{
|
|
215
|
-
turn: turn.turn,
|
|
216
|
-
userEventSeq: turn.user.seq,
|
|
217
|
-
preview: userText(turn.user.data),
|
|
218
|
-
time: turn.user.time,
|
|
219
|
-
},
|
|
220
|
-
],
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function downstreamUsers(turns: readonly ClosedTurn[], start: number): UserMessage[] {
|
|
225
|
-
return turns
|
|
226
|
-
.slice(start)
|
|
227
|
-
.flatMap((turn): UserMessage[] => (turn.user === undefined ? [] : [cloneUser(turn.user.data)]));
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function assistantReplacement(
|
|
231
|
-
event: SessionEvent<"assistant/message">,
|
|
232
|
-
blockIndex: number,
|
|
233
|
-
text: string,
|
|
234
|
-
): AssistantMessage {
|
|
235
|
-
const replaced = replaceTextBlock(event.data.message.content, blockIndex, text).filter(
|
|
236
|
-
(block) => block.type === "text" || block.type === "reasoning",
|
|
237
|
-
);
|
|
238
|
-
return Object.freeze({
|
|
239
|
-
id: randomUUID(),
|
|
240
|
-
role: "assistant",
|
|
241
|
-
content: Object.freeze(replaced),
|
|
242
|
-
source: Object.freeze({
|
|
243
|
-
kind: "model",
|
|
244
|
-
provider: event.data.message.source.provider,
|
|
245
|
-
model: event.data.message.source.model,
|
|
246
|
-
}),
|
|
247
|
-
}) as AssistantMessage;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
interface OperationPlan {
|
|
251
|
-
anchorSeq: number;
|
|
252
|
-
|
|
253
|
-
rewindBoundary?: number;
|
|
254
|
-
version: SessionBranchVersionEvent;
|
|
255
|
-
|
|
256
|
-
manualTurn?: { turn: number; user: UserMessage; assistant: AssistantMessage };
|
|
257
|
-
|
|
258
|
-
queuedUsers: UserMessage[];
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function pairVersionEffect(
|
|
262
|
-
sourceSessionId: SessionId,
|
|
263
|
-
effect: Omit<SessionBranchEffect, "id">,
|
|
264
|
-
): SessionBranchVersionEvent {
|
|
265
|
-
return {
|
|
266
|
-
schemaVersion: SESSION_BRANCH_VERSION_SCHEMA,
|
|
267
|
-
effect: { ...effect, id: randomUUID() },
|
|
268
|
-
inverse: { kind: "restore-version", sessionId: sourceSessionId },
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function editPlan(operation: EditOperation, turns: readonly ClosedTurn[]): OperationPlan {
|
|
273
|
-
const turnIndex = turns.findIndex(
|
|
274
|
-
(turn) =>
|
|
275
|
-
operation.eventSeq > turn.startSeq &&
|
|
276
|
-
(turn.endSeq === undefined || operation.eventSeq < turn.endSeq),
|
|
277
|
-
);
|
|
278
|
-
const turn = turns[turnIndex];
|
|
279
|
-
if (turn === undefined)
|
|
280
|
-
throw new SessionBranchError("所选消息不属于已落定回合。", "INVALID_BOUNDARY");
|
|
281
|
-
const event =
|
|
282
|
-
turn.user?.seq === operation.eventSeq
|
|
283
|
-
? turn.user
|
|
284
|
-
: turn.assistants.find((candidate) => candidate.seq === operation.eventSeq);
|
|
285
|
-
if (event === undefined)
|
|
286
|
-
throw new SessionBranchError("所选消息不存在或不可编辑。", "INVALID_BOUNDARY");
|
|
287
|
-
|
|
288
|
-
if (event.type === "user/message") {
|
|
289
|
-
const before = event.data.content[operation.blockIndex];
|
|
290
|
-
if (before?.type !== "text")
|
|
291
|
-
throw new SessionBranchError("所选用户消息块不是文本。", "INVALID_BOUNDARY");
|
|
292
|
-
const edited = cloneUser(
|
|
293
|
-
event.data,
|
|
294
|
-
replaceTextBlock(event.data.content, operation.blockIndex, operation.text),
|
|
295
|
-
);
|
|
296
|
-
const later = operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : [];
|
|
297
|
-
return {
|
|
298
|
-
anchorSeq: turn.startSeq,
|
|
299
|
-
// 未闭合轮次:rewind 到该 user 消息(exclusive drop),由编辑版替换;
|
|
300
|
-
// 闭合轮次走前一轮 turn/end 的 inclusive 语义。
|
|
301
|
-
...(turn.closed ? {} : { rewindBoundary: event.seq }),
|
|
302
|
-
version: pairVersionEffect(operation.sessionId, {
|
|
303
|
-
operation: "edit",
|
|
304
|
-
cascade: operation.cascade,
|
|
305
|
-
targetTurn: turn.turn,
|
|
306
|
-
targetEventSeq: event.seq,
|
|
307
|
-
targetBlockIndex: operation.blockIndex,
|
|
308
|
-
blockKind: "user",
|
|
309
|
-
before: before.text,
|
|
310
|
-
after: operation.text,
|
|
311
|
-
}),
|
|
312
|
-
queuedUsers: [edited, ...later],
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// 未闭合轮次的助手消息是流式 partial,无最终内容可编辑。
|
|
317
|
-
if (!turn.closed)
|
|
318
|
-
throw new SessionBranchError("未闭合轮次的助手消息不可编辑。", "INVALID_BOUNDARY");
|
|
319
|
-
const before = event.data.message.content[operation.blockIndex];
|
|
320
|
-
if (!isTextualBlock(before))
|
|
321
|
-
throw new SessionBranchError("所选助手消息块不是文本或思考。", "INVALID_BOUNDARY");
|
|
322
|
-
const blockKind: EditableBlockKind =
|
|
323
|
-
before.type === "reasoning" ? "assistant.reasoning" : "assistant.response";
|
|
324
|
-
if (turn.user === undefined)
|
|
325
|
-
throw new SessionBranchError("所选助手消息没有可重建的用户输入。", "INVALID_BOUNDARY");
|
|
326
|
-
return {
|
|
327
|
-
anchorSeq: turn.startSeq,
|
|
328
|
-
version: pairVersionEffect(operation.sessionId, {
|
|
329
|
-
operation: "edit",
|
|
330
|
-
cascade: operation.cascade,
|
|
331
|
-
targetTurn: turn.turn,
|
|
332
|
-
targetEventSeq: event.seq,
|
|
333
|
-
targetBlockIndex: operation.blockIndex,
|
|
334
|
-
blockKind,
|
|
335
|
-
before: before.text,
|
|
336
|
-
after: operation.text,
|
|
337
|
-
}),
|
|
338
|
-
manualTurn: {
|
|
339
|
-
turn: turn.turn,
|
|
340
|
-
user: cloneUser(turn.user.data),
|
|
341
|
-
assistant: assistantReplacement(event, operation.blockIndex, operation.text),
|
|
342
|
-
},
|
|
343
|
-
queuedUsers: operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : [],
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function retryPlan(operation: RetryOperation, turns: readonly ClosedTurn[]): OperationPlan {
|
|
348
|
-
const turnIndex = turns.findIndex((turn) => turn.turn === operation.turn);
|
|
349
|
-
const turn = turns[turnIndex];
|
|
350
|
-
if (turn?.user === undefined)
|
|
351
|
-
throw new SessionBranchError("所选回合没有可重放的用户输入。", "INVALID_BOUNDARY");
|
|
352
|
-
return {
|
|
353
|
-
anchorSeq: turn.startSeq,
|
|
354
|
-
version: pairVersionEffect(operation.sessionId, {
|
|
355
|
-
operation: "retry",
|
|
356
|
-
cascade: operation.cascade,
|
|
357
|
-
targetTurn: turn.turn,
|
|
358
|
-
targetEventSeq: turn.user.seq,
|
|
359
|
-
}),
|
|
360
|
-
queuedUsers:
|
|
361
|
-
operation.cascade === "preserve"
|
|
362
|
-
? downstreamUsers(turns, turnIndex)
|
|
363
|
-
: [cloneUser(turn.user.data)],
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
function rerollPlan(operation: RerollOperation, turns: readonly ClosedTurn[]): OperationPlan {
|
|
368
|
-
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
369
|
-
const turn = turns[index];
|
|
370
|
-
// 未闭合轮次无已落定的助手回复可重生成。
|
|
371
|
-
if (turn?.user === undefined || !turn.closed) continue;
|
|
372
|
-
const target = turn.assistants.findLast((event) =>
|
|
373
|
-
event.data.message.content.some(isTextualBlock),
|
|
374
|
-
);
|
|
375
|
-
if (target === undefined) continue;
|
|
376
|
-
return {
|
|
377
|
-
anchorSeq: turn.startSeq,
|
|
378
|
-
version: pairVersionEffect(operation.sessionId, {
|
|
379
|
-
operation: "reroll",
|
|
380
|
-
cascade: "truncate",
|
|
381
|
-
targetTurn: turn.turn,
|
|
382
|
-
targetEventSeq: target.seq,
|
|
383
|
-
}),
|
|
384
|
-
queuedUsers: [cloneUser(turn.user.data)],
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
throw new SessionBranchError("当前会话没有可重生成的已落定助手回复。", "INVALID_BOUNDARY");
|
|
388
|
-
}
|
|
389
|
-
|
|
390
103
|
function appendLogSeedEvent(
|
|
391
104
|
events: SessionEvent[],
|
|
392
105
|
type: string,
|
|
@@ -574,8 +287,16 @@ export class SessionEditor extends Service {
|
|
|
574
287
|
appendLogSeedEvent(seedSuffix, "session-branch/version", plan.version, true);
|
|
575
288
|
if (plan.manualTurn !== undefined) appendManualTurn(seedSuffix, plan.manualTurn);
|
|
576
289
|
|
|
577
|
-
// 就地编辑:不创建新会话、不改变 id
|
|
578
|
-
//
|
|
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
|
+
|
|
579
300
|
const turnIndex = turns.findIndex((turn) => turn.startSeq === plan.anchorSeq);
|
|
580
301
|
const boundary =
|
|
581
302
|
plan.rewindBoundary !== undefined
|
|
@@ -608,18 +329,19 @@ export class SessionEditor extends Service {
|
|
|
608
329
|
}
|
|
609
330
|
}
|
|
610
331
|
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
+
}
|
|
619
341
|
return {
|
|
620
342
|
sessionId: operation.sessionId,
|
|
621
343
|
queuedTurns,
|
|
622
|
-
// 操作后是否仍有 live owner(
|
|
344
|
+
// 操作后是否仍有 live owner(prepareReplay 可能 resume 出 agent)。
|
|
623
345
|
live: this.ctx.sessions.get(operation.sessionId) !== undefined,
|
|
624
346
|
};
|
|
625
347
|
}
|
|
@@ -641,16 +363,34 @@ export class SessionEditor extends Service {
|
|
|
641
363
|
return (await branch.readRawEvents(sessionId, signal)).events;
|
|
642
364
|
}
|
|
643
365
|
|
|
644
|
-
|
|
366
|
+
/**
|
|
367
|
+
* rewind 前确保 agent 可驱动重放:live agent 先等待其停下(rewind 会截断其
|
|
368
|
+
* session 内存 log,须在 quiescence 后执行);cold 会话先 resume 出驻留
|
|
369
|
+
* agent(此时会话完整,resume 的 prepare 不与截断冲突)。agents 服务缺失或
|
|
370
|
+
* 无需重放时返回空——调用方退化为就地截断版本。
|
|
371
|
+
*/
|
|
372
|
+
private async prepareReplay(
|
|
645
373
|
sessionId: SessionId,
|
|
646
374
|
queuedUsers: readonly UserMessage[],
|
|
647
|
-
signal
|
|
375
|
+
signal: AbortSignal | undefined,
|
|
648
376
|
headerConfig?: { provider?: string; model?: string; maxTokens?: number },
|
|
649
|
-
): Promise<
|
|
650
|
-
if (queuedUsers.length === 0) return
|
|
377
|
+
): Promise<{ agent: EditorAgent | undefined }> {
|
|
378
|
+
if (queuedUsers.length === 0) return { agent: undefined };
|
|
651
379
|
signal?.throwIfAborted();
|
|
652
380
|
const agents = this.ctx.get("agents") as EditorAgentRegistry | undefined;
|
|
653
|
-
if (agents === undefined) return
|
|
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 尚未发生,编辑保持原子(不截断不丢数据)。
|
|
654
394
|
const provider = headerConfig?.provider ?? "";
|
|
655
395
|
const model = headerConfig?.model ?? "";
|
|
656
396
|
if (provider.length === 0 || model.length === 0) {
|
|
@@ -660,51 +400,25 @@ export class SessionEditor extends Service {
|
|
|
660
400
|
.config;
|
|
661
401
|
const fallbackProvider = config?.provider ?? "";
|
|
662
402
|
const fallbackModel = config?.model ?? "";
|
|
663
|
-
if (fallbackProvider.length === 0 || fallbackModel.length === 0)
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
signal,
|
|
668
|
-
fallbackProvider,
|
|
669
|
-
fallbackModel,
|
|
670
|
-
);
|
|
671
|
-
}
|
|
672
|
-
return this.queueThroughAgent(sessionId, queuedUsers, signal, provider, model);
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
private async queueThroughAgent(
|
|
676
|
-
sessionId: SessionId,
|
|
677
|
-
queuedUsers: readonly UserMessage[],
|
|
678
|
-
signal: AbortSignal | undefined,
|
|
679
|
-
provider: string,
|
|
680
|
-
model: string,
|
|
681
|
-
): Promise<number> {
|
|
682
|
-
const agents = this.ctx.get("agents") as EditorAgentRegistry | undefined;
|
|
683
|
-
if (agents === undefined) return 0;
|
|
684
|
-
// 现有 live agent:直接排队输入(其 session 内存已被 rewind 截断,
|
|
685
|
-
// followup 基于截断后历史重放,不改 id、不重建)。
|
|
686
|
-
const existing = agents.get(sessionId);
|
|
687
|
-
if (existing !== undefined) {
|
|
688
|
-
for (const message of queuedUsers) existing.followup(message);
|
|
689
|
-
await this.ctx.sessions.flush(existing.session);
|
|
690
|
-
return queuedUsers.length;
|
|
691
|
-
}
|
|
692
|
-
// cold:resume 已持久化会话(create 会因「已存在持久化日志」失败)。
|
|
693
|
-
// resume 后 agent 驻留,不 dispose(dispose 会破坏客户端打开的窗口)。
|
|
694
|
-
const handle = await agents
|
|
695
|
-
.resume({ resumeSessionId: sessionId, agentOptions: { provider, model } })
|
|
696
|
-
.catch((error: unknown) => {
|
|
697
|
-
// agent 组合失败不应使已 durable 的版本失效:退化为持久化版本。
|
|
698
|
-
this.ctx.logger.warn(
|
|
699
|
-
"session-editor: agent resume failed (%s); version remains durable",
|
|
700
|
-
String(error),
|
|
403
|
+
if (fallbackProvider.length === 0 || fallbackModel.length === 0) {
|
|
404
|
+
throw new SessionBranchError(
|
|
405
|
+
"无法重放:会话没有可解析的模型配置。",
|
|
406
|
+
"INVALID_BOUNDARY",
|
|
701
407
|
);
|
|
702
|
-
|
|
408
|
+
}
|
|
409
|
+
const handle = await agents.resume({
|
|
410
|
+
resumeSessionId: sessionId,
|
|
411
|
+
agentOptions: { provider: fallbackProvider, model: fallbackModel },
|
|
703
412
|
});
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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 };
|
|
708
422
|
}
|
|
709
423
|
}
|
|
710
424
|
|