@morlay/ui-conversation-message-actions 0.0.9
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/LICENSE +21 -0
- package/README.md +87 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +2243 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.mts +208 -0
- package/lib/index.d.mts.map +1 -0
- package/lib/index.mjs +675 -0
- package/lib/index.mjs.map +1 -0
- package/lib/invariant.d.mts +15 -0
- package/lib/invariant.d.mts.map +1 -0
- package/lib/invariant.mjs +22 -0
- package/lib/invariant.mjs.map +1 -0
- package/package.json +77 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { SESSION_BRANCH_VERSION_SCHEMA, SESSION_BRANCH_VERSION_SCHEMA as SESSION_BRANCH_VERSION_SCHEMA$1, SessionBranchError } from "@morlay/session-branch";
|
|
4
|
+
//#region src/shared.ts
|
|
5
|
+
/** 同源 HTTP 端点(host 注册、client fetch)。 */
|
|
6
|
+
const SESSION_EDITOR_PATH = "/session-editor";
|
|
7
|
+
/** 把 {@link BranchTimeline} 投影成 client 用的 {@link SessionEditorTimeline}。 */
|
|
8
|
+
function toTimelinePayload(sessionId, timeline, messages, retryableTurns) {
|
|
9
|
+
const currentPath = /* @__PURE__ */ new Set();
|
|
10
|
+
for (const node of timeline.nodes) currentPath.add(String(node.sessionId));
|
|
11
|
+
const versions = timeline.nodes.map((node) => ({
|
|
12
|
+
sessionId: String(node.sessionId),
|
|
13
|
+
...node.parentSessionId === void 0 ? {} : { parentSessionId: String(node.parentSessionId) },
|
|
14
|
+
...node.effect === void 0 ? {} : {
|
|
15
|
+
effectId: node.effect.id,
|
|
16
|
+
inverseSessionId: String(node.inverseSessionId),
|
|
17
|
+
operation: node.effect.operation,
|
|
18
|
+
cascade: node.effect.cascade,
|
|
19
|
+
targetTurn: node.effect.targetTurn,
|
|
20
|
+
...node.effect.blockKind === void 0 ? {} : { blockKind: node.effect.blockKind },
|
|
21
|
+
...node.effect.before === void 0 ? {} : { before: node.effect.before },
|
|
22
|
+
...node.effect.after === void 0 ? {} : { after: node.effect.after }
|
|
23
|
+
},
|
|
24
|
+
createdAt: node.createdAt,
|
|
25
|
+
depth: depthOf(timeline, node.sessionId),
|
|
26
|
+
current: String(node.sessionId) === String(sessionId),
|
|
27
|
+
onCurrentEffectPath: currentPath.has(String(node.sessionId))
|
|
28
|
+
}));
|
|
29
|
+
const versionsById = new Map(versions.map((version) => [version.sessionId, version]));
|
|
30
|
+
const undoStack = [];
|
|
31
|
+
let cursor = versionsById.get(String(sessionId));
|
|
32
|
+
while (cursor?.inverseSessionId !== void 0) {
|
|
33
|
+
if (undoStack.includes(cursor.inverseSessionId)) break;
|
|
34
|
+
undoStack.push(cursor.inverseSessionId);
|
|
35
|
+
cursor = versionsById.get(cursor.inverseSessionId);
|
|
36
|
+
}
|
|
37
|
+
const redoSessionIds = versions.filter((version) => version.inverseSessionId === String(sessionId)).map((version) => version.sessionId);
|
|
38
|
+
return {
|
|
39
|
+
sessionId: String(sessionId),
|
|
40
|
+
messages,
|
|
41
|
+
retryableTurns,
|
|
42
|
+
versions,
|
|
43
|
+
undoStack,
|
|
44
|
+
redoSessionIds
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** 计算节点到根的深度。 */
|
|
48
|
+
function depthOf(timeline, sessionId) {
|
|
49
|
+
const byId = new Map(timeline.nodes.map((node) => [String(node.sessionId), node]));
|
|
50
|
+
let depth = 0;
|
|
51
|
+
let cursor = byId.get(String(sessionId));
|
|
52
|
+
const seen = /* @__PURE__ */ new Set();
|
|
53
|
+
while (cursor?.parentSessionId !== void 0 && !seen.has(String(cursor.sessionId))) {
|
|
54
|
+
seen.add(String(cursor.sessionId));
|
|
55
|
+
depth += 1;
|
|
56
|
+
cursor = byId.get(String(cursor.parentSessionId));
|
|
57
|
+
}
|
|
58
|
+
return depth;
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/index.ts
|
|
62
|
+
/**
|
|
63
|
+
* 编排层 `SessionEditor`:在 `@morlay/session-branch` 的 provider 抽象之上
|
|
64
|
+
* 组装 rewind / retry / fork 的**完整功能**(参考 `dsh-message-edit` 的
|
|
65
|
+
* 产品语义:edit / reroll / retry + 级联策略 + 版本树)。
|
|
66
|
+
*
|
|
67
|
+
* 分层边界:
|
|
68
|
+
* - 数据层(forkFrom / rewind / readBranchPrefix)由 `ctx.sessionBranch`
|
|
69
|
+
* (provider 实现,如 `@morlay/session-rdb`)提供;
|
|
70
|
+
* - 本服务只做**编排**:闭合轮次扫描、版本效果事件构造、派生 seed 组装、
|
|
71
|
+
* rewind 命令透传、版本树投影;
|
|
72
|
+
* - agent 驱动(重放用户输入 / 创建子 agent)是**可选增强**:`agents`
|
|
73
|
+
* 服务以 duck-typed 接口使用(不硬依赖 `@deepseek-ai/dsh-agent`),
|
|
74
|
+
* 缺失时退化为「创建持久化版本」——版本已 durable,之后可随时 resume。
|
|
75
|
+
*
|
|
76
|
+
* @module @morlay/ui-conversation-message-actions
|
|
77
|
+
*/
|
|
78
|
+
function closedTurns(events) {
|
|
79
|
+
const result = [];
|
|
80
|
+
let current;
|
|
81
|
+
for (const event of events) {
|
|
82
|
+
if (event.type === "turn/start") {
|
|
83
|
+
current = {
|
|
84
|
+
turn: event.data.turn,
|
|
85
|
+
startSeq: event.seq,
|
|
86
|
+
assistants: []
|
|
87
|
+
};
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (current === void 0) continue;
|
|
91
|
+
if (event.type === "user/message" && current.user === void 0 && event.data.source.kind === "user") {
|
|
92
|
+
current.user = event;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (event.type === "assistant/message" && event.data.turn === current.turn) {
|
|
96
|
+
current.assistants.push(event);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (event.type === "turn/end" && event.data.turn === current.turn) {
|
|
100
|
+
result.push({
|
|
101
|
+
...current,
|
|
102
|
+
endSeq: event.seq
|
|
103
|
+
});
|
|
104
|
+
current = void 0;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
function isTextualBlock(block) {
|
|
110
|
+
return block?.type === "text" || block?.type === "reasoning";
|
|
111
|
+
}
|
|
112
|
+
function userText(message) {
|
|
113
|
+
return message.content.filter((block) => block.type === "text").map((block) => block.text).join("\n");
|
|
114
|
+
}
|
|
115
|
+
function cloneUser(message, content = structuredClone(message.content)) {
|
|
116
|
+
return Object.freeze({
|
|
117
|
+
id: randomUUID(),
|
|
118
|
+
role: "user",
|
|
119
|
+
content: Object.freeze(content),
|
|
120
|
+
source: Object.freeze({ kind: "user" })
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function replaceTextBlock(content, blockIndex, text) {
|
|
124
|
+
const block = content[blockIndex];
|
|
125
|
+
if (!isTextualBlock(block)) throw new SessionBranchError("所选内容块不是可编辑文本。", "INVALID_BOUNDARY");
|
|
126
|
+
return content.map((candidate, index) => index === blockIndex ? {
|
|
127
|
+
...candidate,
|
|
128
|
+
text
|
|
129
|
+
} : structuredClone(candidate));
|
|
130
|
+
}
|
|
131
|
+
function editableMessages(turns) {
|
|
132
|
+
const result = [];
|
|
133
|
+
for (const turn of turns) {
|
|
134
|
+
if (turn.user !== void 0) for (const [blockIndex, block] of turn.user.data.content.entries()) {
|
|
135
|
+
if (block.type !== "text") continue;
|
|
136
|
+
result.push({
|
|
137
|
+
key: `${String(turn.user.seq)}:${String(blockIndex)}`,
|
|
138
|
+
turn: turn.turn,
|
|
139
|
+
eventSeq: turn.user.seq,
|
|
140
|
+
blockIndex,
|
|
141
|
+
kind: "user",
|
|
142
|
+
text: block.text,
|
|
143
|
+
time: turn.user.time
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
for (const event of turn.assistants) for (const [blockIndex, block] of event.data.message.content.entries()) {
|
|
147
|
+
if (!isTextualBlock(block)) continue;
|
|
148
|
+
result.push({
|
|
149
|
+
key: `${String(event.seq)}:${String(blockIndex)}`,
|
|
150
|
+
turn: turn.turn,
|
|
151
|
+
eventSeq: event.seq,
|
|
152
|
+
blockIndex,
|
|
153
|
+
kind: block.type === "reasoning" ? "assistant.reasoning" : "assistant.response",
|
|
154
|
+
text: block.text,
|
|
155
|
+
time: event.time
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
function retryableTurns(turns) {
|
|
162
|
+
return turns.flatMap((turn) => turn.user === void 0 ? [] : [{
|
|
163
|
+
turn: turn.turn,
|
|
164
|
+
userEventSeq: turn.user.seq,
|
|
165
|
+
preview: userText(turn.user.data),
|
|
166
|
+
time: turn.user.time
|
|
167
|
+
}]);
|
|
168
|
+
}
|
|
169
|
+
function downstreamUsers(turns, start) {
|
|
170
|
+
return turns.slice(start).flatMap((turn) => turn.user === void 0 ? [] : [cloneUser(turn.user.data)]);
|
|
171
|
+
}
|
|
172
|
+
function assistantReplacement(event, blockIndex, text) {
|
|
173
|
+
const replaced = replaceTextBlock(event.data.message.content, blockIndex, text).filter((block) => block.type === "text" || block.type === "reasoning");
|
|
174
|
+
return Object.freeze({
|
|
175
|
+
id: randomUUID(),
|
|
176
|
+
role: "assistant",
|
|
177
|
+
content: Object.freeze(replaced),
|
|
178
|
+
source: Object.freeze({
|
|
179
|
+
kind: "model",
|
|
180
|
+
provider: event.data.message.source.provider,
|
|
181
|
+
model: event.data.message.source.model
|
|
182
|
+
})
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
function pairVersionEffect(sourceSessionId, effect) {
|
|
186
|
+
return {
|
|
187
|
+
schemaVersion: SESSION_BRANCH_VERSION_SCHEMA$1,
|
|
188
|
+
effect: {
|
|
189
|
+
...effect,
|
|
190
|
+
id: randomUUID()
|
|
191
|
+
},
|
|
192
|
+
inverse: {
|
|
193
|
+
kind: "restore-version",
|
|
194
|
+
sessionId: sourceSessionId
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function editPlan(operation, turns) {
|
|
199
|
+
const turnIndex = turns.findIndex((turn) => operation.eventSeq > turn.startSeq && operation.eventSeq < turn.endSeq);
|
|
200
|
+
const turn = turns[turnIndex];
|
|
201
|
+
if (turn === void 0) throw new SessionBranchError("所选消息不属于已落定回合。", "INVALID_BOUNDARY");
|
|
202
|
+
const event = turn.user?.seq === operation.eventSeq ? turn.user : turn.assistants.find((candidate) => candidate.seq === operation.eventSeq);
|
|
203
|
+
if (event === void 0) throw new SessionBranchError("所选消息不存在或不可编辑。", "INVALID_BOUNDARY");
|
|
204
|
+
if (event.type === "user/message") {
|
|
205
|
+
const before = event.data.content[operation.blockIndex];
|
|
206
|
+
if (before?.type !== "text") throw new SessionBranchError("所选用户消息块不是文本。", "INVALID_BOUNDARY");
|
|
207
|
+
const edited = cloneUser(event.data, replaceTextBlock(event.data.content, operation.blockIndex, operation.text));
|
|
208
|
+
const later = operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : [];
|
|
209
|
+
return {
|
|
210
|
+
anchorSeq: turn.startSeq,
|
|
211
|
+
version: pairVersionEffect(operation.sessionId, {
|
|
212
|
+
operation: "edit",
|
|
213
|
+
cascade: operation.cascade,
|
|
214
|
+
targetTurn: turn.turn,
|
|
215
|
+
targetEventSeq: event.seq,
|
|
216
|
+
targetBlockIndex: operation.blockIndex,
|
|
217
|
+
blockKind: "user",
|
|
218
|
+
before: before.text,
|
|
219
|
+
after: operation.text
|
|
220
|
+
}),
|
|
221
|
+
queuedUsers: [edited, ...later]
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const before = event.data.message.content[operation.blockIndex];
|
|
225
|
+
if (!isTextualBlock(before)) throw new SessionBranchError("所选助手消息块不是文本或思考。", "INVALID_BOUNDARY");
|
|
226
|
+
const blockKind = before.type === "reasoning" ? "assistant.reasoning" : "assistant.response";
|
|
227
|
+
if (turn.user === void 0) throw new SessionBranchError("所选助手消息没有可重建的用户输入。", "INVALID_BOUNDARY");
|
|
228
|
+
return {
|
|
229
|
+
anchorSeq: turn.startSeq,
|
|
230
|
+
version: pairVersionEffect(operation.sessionId, {
|
|
231
|
+
operation: "edit",
|
|
232
|
+
cascade: operation.cascade,
|
|
233
|
+
targetTurn: turn.turn,
|
|
234
|
+
targetEventSeq: event.seq,
|
|
235
|
+
targetBlockIndex: operation.blockIndex,
|
|
236
|
+
blockKind,
|
|
237
|
+
before: before.text,
|
|
238
|
+
after: operation.text
|
|
239
|
+
}),
|
|
240
|
+
manualTurn: {
|
|
241
|
+
turn: turn.turn,
|
|
242
|
+
user: cloneUser(turn.user.data),
|
|
243
|
+
assistant: assistantReplacement(event, operation.blockIndex, operation.text)
|
|
244
|
+
},
|
|
245
|
+
queuedUsers: operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : []
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function retryPlan(operation, turns) {
|
|
249
|
+
const turnIndex = turns.findIndex((turn) => turn.turn === operation.turn);
|
|
250
|
+
const turn = turns[turnIndex];
|
|
251
|
+
if (turn?.user === void 0) throw new SessionBranchError("所选回合没有可重放的用户输入。", "INVALID_BOUNDARY");
|
|
252
|
+
return {
|
|
253
|
+
anchorSeq: turn.startSeq,
|
|
254
|
+
version: pairVersionEffect(operation.sessionId, {
|
|
255
|
+
operation: "retry",
|
|
256
|
+
cascade: operation.cascade,
|
|
257
|
+
targetTurn: turn.turn,
|
|
258
|
+
targetEventSeq: turn.user.seq
|
|
259
|
+
}),
|
|
260
|
+
queuedUsers: operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex) : [cloneUser(turn.user.data)]
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function rerollPlan(operation, turns) {
|
|
264
|
+
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
265
|
+
const turn = turns[index];
|
|
266
|
+
if (turn?.user === void 0) continue;
|
|
267
|
+
const target = turn.assistants.findLast((event) => event.data.message.content.some(isTextualBlock));
|
|
268
|
+
if (target === void 0) continue;
|
|
269
|
+
return {
|
|
270
|
+
anchorSeq: turn.startSeq,
|
|
271
|
+
version: pairVersionEffect(operation.sessionId, {
|
|
272
|
+
operation: "reroll",
|
|
273
|
+
cascade: "truncate",
|
|
274
|
+
targetTurn: turn.turn,
|
|
275
|
+
targetEventSeq: target.seq
|
|
276
|
+
}),
|
|
277
|
+
queuedUsers: [cloneUser(turn.user.data)]
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
throw new SessionBranchError("当前会话没有可重生成的已落定助手回复。", "INVALID_BOUNDARY");
|
|
281
|
+
}
|
|
282
|
+
/** —— 种子事件构造(本地纯事件构造器;Session 构造时统一验证) —— */
|
|
283
|
+
function appendLogSeedEvent(events, type, data, ignorable = false) {
|
|
284
|
+
events.push({
|
|
285
|
+
type,
|
|
286
|
+
seq: events.length,
|
|
287
|
+
time: Date.now(),
|
|
288
|
+
data,
|
|
289
|
+
...ignorable ? { ignorable: true } : {}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
function appendSurfaceSeedEvent(events, type, data, intent) {
|
|
293
|
+
events.push({
|
|
294
|
+
type,
|
|
295
|
+
seq: events.length,
|
|
296
|
+
time: Date.now(),
|
|
297
|
+
data,
|
|
298
|
+
surfaceOp: intent.surfaceOp,
|
|
299
|
+
...intent.sourceEventSeqs === void 0 ? {} : { sourceEventSeqs: intent.sourceEventSeqs }
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
function appendManualTurn(events, manual) {
|
|
303
|
+
const { turn, user, assistant } = manual;
|
|
304
|
+
appendLogSeedEvent(events, "turn/start", { turn });
|
|
305
|
+
appendSurfaceSeedEvent(events, "user/message", user, { surfaceOp: "append" });
|
|
306
|
+
appendLogSeedEvent(events, "step/start", {
|
|
307
|
+
turn,
|
|
308
|
+
step: 1
|
|
309
|
+
});
|
|
310
|
+
appendSurfaceSeedEvent(events, "assistant/message", {
|
|
311
|
+
turn,
|
|
312
|
+
step: 1,
|
|
313
|
+
message: assistant
|
|
314
|
+
}, {
|
|
315
|
+
surfaceOp: "append",
|
|
316
|
+
sourceEventSeqs: []
|
|
317
|
+
});
|
|
318
|
+
appendLogSeedEvent(events, "step/end", {
|
|
319
|
+
turn,
|
|
320
|
+
step: 1
|
|
321
|
+
});
|
|
322
|
+
appendLogSeedEvent(events, "turn/end", {
|
|
323
|
+
turn,
|
|
324
|
+
reason: { kind: "completed" }
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* 把派生 seed 后缀落地到 **live** 会话。上游 `Session.append` 不保留
|
|
329
|
+
* `ignorable` 标记(只保留 type/data/surface 元数据),而版本效果事件必须
|
|
330
|
+
* 保持 ignorable(live log 保留、不落 canonical log),因此:
|
|
331
|
+
* - ignorable 事件(版本效果)直接 push 内存 log(不发布 `session/event`;
|
|
332
|
+
* 服务端语义事件,客户端无需感知;surfaceManager 对非 surface 事件 fold
|
|
333
|
+
* 无副作用);
|
|
334
|
+
* - 其余事件(手工回合 manualTurn)走 `append`(surface 校验 + 发布)。
|
|
335
|
+
* 后续统一由 write-behind flush 落盘(RDB 稠密化时过滤 ignorable)。
|
|
336
|
+
*/
|
|
337
|
+
function appendSeedSuffixLive(session, seedSuffix) {
|
|
338
|
+
for (const event of seedSuffix) {
|
|
339
|
+
if (event.ignorable === true) {
|
|
340
|
+
const s = session;
|
|
341
|
+
s.log.push({
|
|
342
|
+
...event,
|
|
343
|
+
seq: s.log.length
|
|
344
|
+
});
|
|
345
|
+
s.eventsSnapshot = void 0;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const s = session;
|
|
349
|
+
const raw = event;
|
|
350
|
+
if (raw.surfaceOp !== void 0) s.append(event.type, event.data, {
|
|
351
|
+
surfaceOp: raw.surfaceOp,
|
|
352
|
+
...raw.sourceEventSeqs === void 0 ? {} : { sourceEventSeqs: raw.sourceEventSeqs }
|
|
353
|
+
});
|
|
354
|
+
else s.append(event.type, event.data);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/** —— 编排服务 —— */
|
|
358
|
+
/**
|
|
359
|
+
* `ctx.sessionEditor`:rewind / retry / fork 的完整功能编排。
|
|
360
|
+
* 组合 `ctx.sessionBranch`(provider 抽象)与可选的 `agents` 服务。
|
|
361
|
+
*/
|
|
362
|
+
var SessionEditor = class extends Service {
|
|
363
|
+
static inject = [
|
|
364
|
+
"sessionBranch",
|
|
365
|
+
"sessionPersistence",
|
|
366
|
+
"sessions"
|
|
367
|
+
];
|
|
368
|
+
constructor(ctx) {
|
|
369
|
+
super(ctx, "sessionEditor");
|
|
370
|
+
registerHttpRoutes(ctx);
|
|
371
|
+
}
|
|
372
|
+
/** 定位 `atSeq` 锚定的闭合边界(透传 provider)。 */
|
|
373
|
+
readBranchPrefix(id, atSeq, mode, signal) {
|
|
374
|
+
return this.ctx.sessionBranch.readBranchPrefix(id, atSeq, mode, signal);
|
|
375
|
+
}
|
|
376
|
+
/** 从持久化源派生新会话(透传 provider;不驱动 agent)。 */
|
|
377
|
+
fork(sourceId, atSeq, childSessionId, meta, signal) {
|
|
378
|
+
return this.ctx.sessionBranch.forkFrom(sourceId, {
|
|
379
|
+
...atSeq === void 0 ? {} : { atSeq },
|
|
380
|
+
...childSessionId === void 0 ? {} : { childSessionId },
|
|
381
|
+
...meta === void 0 ? {} : { meta }
|
|
382
|
+
}, signal);
|
|
383
|
+
}
|
|
384
|
+
/** 截断式回退:原会话回退到闭合边界(透传 provider)。 */
|
|
385
|
+
rewind(id, toBoundary, signal) {
|
|
386
|
+
return this.ctx.sessionBranch.rewind(id, toBoundary, signal);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* 清洗一个会话的 surface/provenance 坐标为稠密空间(透传 sessionBranch)。
|
|
390
|
+
* 历史加载失败(坐标混叠导致 seed 校验失败)时调用,清洗后重新加载即可。
|
|
391
|
+
* `cleanseSession` 是 rdb 实现层新增的服务面(契约层 `SessionBranch`
|
|
392
|
+
* 没有此方法),经运行时访问。
|
|
393
|
+
*/
|
|
394
|
+
cleanseSession(sessionId, signal) {
|
|
395
|
+
return this.ctx.sessionBranch.cleanseSession(sessionId, signal);
|
|
396
|
+
}
|
|
397
|
+
/** 完整版本树投影(透传 provider 组合)。 */
|
|
398
|
+
timeline(sessionId, signal) {
|
|
399
|
+
return this.ctx.sessionBranch.timeline(sessionId, signal);
|
|
400
|
+
}
|
|
401
|
+
/** 编辑一个已落定文本块并从其轮次边界分支。 */
|
|
402
|
+
edit(operation, signal) {
|
|
403
|
+
return this.branchOperation(operation, signal);
|
|
404
|
+
}
|
|
405
|
+
/** 重生成最后一条已落定助手回复。 */
|
|
406
|
+
reroll(operation, signal) {
|
|
407
|
+
return this.branchOperation(operation, signal);
|
|
408
|
+
}
|
|
409
|
+
/** 重试任意历史回合。 */
|
|
410
|
+
retry(operation, signal) {
|
|
411
|
+
return this.branchOperation(operation, signal);
|
|
412
|
+
}
|
|
413
|
+
/** 可编辑消息块枚举(Timeline 编辑面)。 */
|
|
414
|
+
async editableMessages(sessionId, signal) {
|
|
415
|
+
return editableMessages(closedTurns(await this.readEvents(sessionId, signal)));
|
|
416
|
+
}
|
|
417
|
+
/** 可重试回合枚举(Timeline 重试面)。 */
|
|
418
|
+
async retryableTurns(sessionId, signal) {
|
|
419
|
+
return retryableTurns(closedTurns(await this.readEvents(sessionId, signal)));
|
|
420
|
+
}
|
|
421
|
+
/** 执行一个分支式操作(edit / reroll / retry)。 */
|
|
422
|
+
async branchOperation(operation, signal) {
|
|
423
|
+
signal?.throwIfAborted();
|
|
424
|
+
const events = await this.readEvents(operation.sessionId, signal);
|
|
425
|
+
const turns = closedTurns(events);
|
|
426
|
+
const plan = operation.action === "edit" ? editPlan(operation, turns) : operation.action === "retry" ? retryPlan(operation, turns) : rerollPlan(operation, turns);
|
|
427
|
+
const headerConfig = events.findLast((event) => event.type === "request/header")?.data.header.config;
|
|
428
|
+
const seedSuffix = [];
|
|
429
|
+
appendLogSeedEvent(seedSuffix, "session-branch/version", plan.version, true);
|
|
430
|
+
if (plan.manualTurn !== void 0) appendManualTurn(seedSuffix, plan.manualTurn);
|
|
431
|
+
const turnIndex = turns.findIndex((turn) => turn.startSeq === plan.anchorSeq);
|
|
432
|
+
const boundary = turnIndex <= 0 ? -1 : turns[turnIndex - 1].endSeq;
|
|
433
|
+
const live = this.ctx.sessions.get(operation.sessionId);
|
|
434
|
+
await this.ctx.sessionBranch.rewind(operation.sessionId, boundary, signal);
|
|
435
|
+
if (seedSuffix.length > 0) {
|
|
436
|
+
if (live !== void 0) {
|
|
437
|
+
appendSeedSuffixLive(live, seedSuffix);
|
|
438
|
+
this.ctx.sessionBranch.syncLiveCursor(operation.sessionId);
|
|
439
|
+
await this.ctx.sessions.flush(live);
|
|
440
|
+
} else {
|
|
441
|
+
const renumbered = seedSuffix.map((event, index) => ({
|
|
442
|
+
...event,
|
|
443
|
+
seq: boundary + 1 + index
|
|
444
|
+
}));
|
|
445
|
+
await this.ctx.sessionPersistence.append(operation.sessionId, renumbered);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const queuedTurns = await this.driveAgent(operation.sessionId, plan.queuedUsers, signal, headerConfig);
|
|
449
|
+
return {
|
|
450
|
+
sessionId: operation.sessionId,
|
|
451
|
+
queuedTurns,
|
|
452
|
+
live: this.ctx.sessions.get(operation.sessionId) !== void 0
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/** 读取会话事件:live 优先,否则走持久化 inspect。 */
|
|
456
|
+
async readEvents(sessionId, signal) {
|
|
457
|
+
const live = this.ctx.sessions.get(sessionId);
|
|
458
|
+
if (live !== void 0) return live.events;
|
|
459
|
+
return (await this.ctx.sessionPersistence.inspect(sessionId, signal)).events;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* 可选 agent 驱动:优先复用现有 live agent(用户会话驻留——rewind 已截断
|
|
463
|
+
* 其 session 内存,followup 会基于截断后历史重放),否则 `resume` 已持久化
|
|
464
|
+
* 会话。模型配置来自 branchOperation 在 rewind 前解析的 `headerConfig`
|
|
465
|
+
* (rewind 可能截断 request/header);缺失时回退到当前 events。无 agents
|
|
466
|
+
* 服务、无可用模型路由或恢复失败时退化为持久化版本(已 durable)。
|
|
467
|
+
* 返回实际排队的输入数。
|
|
468
|
+
*/
|
|
469
|
+
async driveAgent(sessionId, queuedUsers, signal, headerConfig) {
|
|
470
|
+
if (queuedUsers.length === 0) return 0;
|
|
471
|
+
signal?.throwIfAborted();
|
|
472
|
+
if (this.ctx.get("agents") === void 0) return 0;
|
|
473
|
+
const provider = headerConfig?.provider ?? "";
|
|
474
|
+
const model = headerConfig?.model ?? "";
|
|
475
|
+
if (provider.length === 0 || model.length === 0) {
|
|
476
|
+
const config = (await this.readEvents(sessionId, signal)).findLast((event) => event.type === "request/header")?.data.header.config;
|
|
477
|
+
const fallbackProvider = config?.provider ?? "";
|
|
478
|
+
const fallbackModel = config?.model ?? "";
|
|
479
|
+
if (fallbackProvider.length === 0 || fallbackModel.length === 0) return 0;
|
|
480
|
+
return this.queueThroughAgent(sessionId, queuedUsers, signal, fallbackProvider, fallbackModel);
|
|
481
|
+
}
|
|
482
|
+
return this.queueThroughAgent(sessionId, queuedUsers, signal, provider, model);
|
|
483
|
+
}
|
|
484
|
+
/** 经 live agent(复用)或 resume(重建)排队重放输入;返回排队数。 */
|
|
485
|
+
async queueThroughAgent(sessionId, queuedUsers, signal, provider, model) {
|
|
486
|
+
const agents = this.ctx.get("agents");
|
|
487
|
+
if (agents === void 0) return 0;
|
|
488
|
+
const existing = agents.get(sessionId);
|
|
489
|
+
if (existing !== void 0) {
|
|
490
|
+
for (const message of queuedUsers) existing.followup(message);
|
|
491
|
+
await this.ctx.sessions.flush(existing.session);
|
|
492
|
+
return queuedUsers.length;
|
|
493
|
+
}
|
|
494
|
+
const handle = await agents.resume({
|
|
495
|
+
resumeSessionId: sessionId,
|
|
496
|
+
agentOptions: {
|
|
497
|
+
provider,
|
|
498
|
+
model
|
|
499
|
+
}
|
|
500
|
+
}).catch((error) => {
|
|
501
|
+
this.ctx.logger.warn("session-editor: agent resume failed (%s); version remains durable", String(error));
|
|
502
|
+
});
|
|
503
|
+
if (handle === void 0) return 0;
|
|
504
|
+
for (const message of queuedUsers) handle.agent.followup(message);
|
|
505
|
+
await this.ctx.sessions.flush(handle.agent.session);
|
|
506
|
+
return queuedUsers.length;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
function objectValue(value) {
|
|
510
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("请求体必须是 JSON 对象。");
|
|
511
|
+
return value;
|
|
512
|
+
}
|
|
513
|
+
function sessionIdOf(value) {
|
|
514
|
+
if (typeof value !== "string" || value.length === 0) throw new TypeError("sessionId 必须是非空字符串。");
|
|
515
|
+
return value;
|
|
516
|
+
}
|
|
517
|
+
function integerOf(value, name) {
|
|
518
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} 必须是非负安全整数。`);
|
|
519
|
+
return value;
|
|
520
|
+
}
|
|
521
|
+
function cascadeOf(value) {
|
|
522
|
+
if (value !== "truncate" && value !== "preserve") throw new TypeError("cascade 必须是 truncate 或 preserve。");
|
|
523
|
+
return value;
|
|
524
|
+
}
|
|
525
|
+
function decodeOperation(value) {
|
|
526
|
+
const record = objectValue(value);
|
|
527
|
+
const sessionId = sessionIdOf(record["sessionId"]);
|
|
528
|
+
switch (record["action"]) {
|
|
529
|
+
case "edit":
|
|
530
|
+
if (typeof record["text"] !== "string") throw new TypeError("text 必须是字符串。");
|
|
531
|
+
return {
|
|
532
|
+
action: "edit",
|
|
533
|
+
sessionId,
|
|
534
|
+
eventSeq: integerOf(record["eventSeq"], "eventSeq"),
|
|
535
|
+
blockIndex: integerOf(record["blockIndex"], "blockIndex"),
|
|
536
|
+
text: record["text"],
|
|
537
|
+
cascade: cascadeOf(record["cascade"])
|
|
538
|
+
};
|
|
539
|
+
case "reroll": return {
|
|
540
|
+
action: "reroll",
|
|
541
|
+
sessionId
|
|
542
|
+
};
|
|
543
|
+
case "retry": return {
|
|
544
|
+
action: "retry",
|
|
545
|
+
sessionId,
|
|
546
|
+
turn: integerOf(record["turn"], "turn"),
|
|
547
|
+
cascade: cascadeOf(record["cascade"])
|
|
548
|
+
};
|
|
549
|
+
case "rewind": return {
|
|
550
|
+
action: "rewind",
|
|
551
|
+
sessionId,
|
|
552
|
+
toBoundary: integerOf(record["toBoundary"], "toBoundary")
|
|
553
|
+
};
|
|
554
|
+
case "fork": return {
|
|
555
|
+
action: "fork",
|
|
556
|
+
sessionId,
|
|
557
|
+
...record["atSeq"] === void 0 ? {} : { atSeq: integerOf(record["atSeq"], "atSeq") },
|
|
558
|
+
...record["childSessionId"] === void 0 ? {} : { childSessionId: sessionIdOf(record["childSessionId"]) }
|
|
559
|
+
};
|
|
560
|
+
case "cleanse": return {
|
|
561
|
+
action: "cleanse",
|
|
562
|
+
sessionId
|
|
563
|
+
};
|
|
564
|
+
default: throw new TypeError("action 必须是 edit、reroll、retry、rewind、fork 或 cleanse。");
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
function requestJson(request) {
|
|
568
|
+
return new Promise((resolve, reject) => {
|
|
569
|
+
const decoder = new TextDecoder();
|
|
570
|
+
let text = "";
|
|
571
|
+
request.on("data", (chunk) => {
|
|
572
|
+
text += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
573
|
+
});
|
|
574
|
+
request.on("end", () => {
|
|
575
|
+
try {
|
|
576
|
+
text += decoder.decode();
|
|
577
|
+
resolve(JSON.parse(text));
|
|
578
|
+
} catch (error) {
|
|
579
|
+
reject(error);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
request.on("error", reject);
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
function respondJson(response, status, value) {
|
|
586
|
+
response.writeHead(status, {
|
|
587
|
+
"content-type": "application/json; charset=utf-8",
|
|
588
|
+
"cache-control": "no-store"
|
|
589
|
+
});
|
|
590
|
+
response.end(JSON.stringify(value));
|
|
591
|
+
}
|
|
592
|
+
/** GET 投影:timeline + 编辑/重试面。 */
|
|
593
|
+
async function readTimeline(editor, sessionId) {
|
|
594
|
+
return toTimelinePayload(sessionId, await editor.timeline(sessionId), await editor.editableMessages(sessionId), await editor.retryableTurns(sessionId));
|
|
595
|
+
}
|
|
596
|
+
async function runOperation(editor, operation) {
|
|
597
|
+
switch (operation.action) {
|
|
598
|
+
case "edit": {
|
|
599
|
+
const result = await editor.edit(operation);
|
|
600
|
+
return {
|
|
601
|
+
sessionId: result.sessionId,
|
|
602
|
+
queuedTurns: result.queuedTurns,
|
|
603
|
+
...result.live === void 0 ? {} : { live: result.live }
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
case "reroll": {
|
|
607
|
+
const result = await editor.reroll(operation);
|
|
608
|
+
return {
|
|
609
|
+
sessionId: result.sessionId,
|
|
610
|
+
queuedTurns: result.queuedTurns,
|
|
611
|
+
...result.live === void 0 ? {} : { live: result.live }
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
case "retry": {
|
|
615
|
+
const result = await editor.retry(operation);
|
|
616
|
+
return {
|
|
617
|
+
sessionId: result.sessionId,
|
|
618
|
+
queuedTurns: result.queuedTurns,
|
|
619
|
+
...result.live === void 0 ? {} : { live: result.live }
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
case "rewind":
|
|
623
|
+
await editor.rewind(operation.sessionId, operation.toBoundary);
|
|
624
|
+
return {
|
|
625
|
+
sessionId: operation.sessionId,
|
|
626
|
+
queuedTurns: 0
|
|
627
|
+
};
|
|
628
|
+
case "fork": return {
|
|
629
|
+
sessionId: await editor.fork(operation.sessionId, operation.atSeq, operation.childSessionId),
|
|
630
|
+
queuedTurns: 0
|
|
631
|
+
};
|
|
632
|
+
case "cleanse": {
|
|
633
|
+
const { changed } = await editor.cleanseSession(operation.sessionId);
|
|
634
|
+
return {
|
|
635
|
+
sessionId: operation.sessionId,
|
|
636
|
+
queuedTurns: 0,
|
|
637
|
+
changed
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
async function handleRoute(editor, request, response) {
|
|
643
|
+
try {
|
|
644
|
+
if (request.method === "GET") {
|
|
645
|
+
respondJson(response, 200, await readTimeline(editor, sessionIdOf(new URL(request.url ?? "/session-editor", "http://session-editor.local").searchParams.get("sessionId"))));
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (request.method === "POST") {
|
|
649
|
+
respondJson(response, 200, await runOperation(editor, decodeOperation(await requestJson(request))));
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
response.writeHead(405);
|
|
653
|
+
response.end();
|
|
654
|
+
} catch (error) {
|
|
655
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
656
|
+
respondJson(response, error instanceof TypeError ? 400 : 409, { error: message });
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/** 注册同源 HTTP 路由(`ctx.webServer` 由 dsh web 的 webserver 包提供;缺失时跳过)。 */
|
|
660
|
+
function registerHttpRoutes(ctx) {
|
|
661
|
+
const webServer = ctx.get("webServer");
|
|
662
|
+
if (webServer === void 0) return;
|
|
663
|
+
ctx.effect(() => {
|
|
664
|
+
const editor = ctx.sessionEditor;
|
|
665
|
+
return webServer.register({
|
|
666
|
+
kind: "exact",
|
|
667
|
+
path: SESSION_EDITOR_PATH,
|
|
668
|
+
handler: (request, response) => handleRoute(editor, request, response)
|
|
669
|
+
});
|
|
670
|
+
}, "session-editor: HTTP route");
|
|
671
|
+
}
|
|
672
|
+
//#endregion
|
|
673
|
+
export { SESSION_BRANCH_VERSION_SCHEMA, SessionEditor, SessionEditor as default, closedTurns, editableMessages, retryableTurns };
|
|
674
|
+
|
|
675
|
+
//# sourceMappingURL=index.mjs.map
|