@morlay/ui-conversation-message-actions 0.0.17 → 0.0.19-alpha.0
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/README.md +29 -98
- package/dist/client.cjs +1380 -0
- package/dist/client.d.cts +6 -0
- package/dist/client.d.mts +6 -0
- package/dist/index.d.cts +98 -0
- package/dist/index.d.mts +3 -10
- package/dist/index.mjs +460 -2
- package/dist/invariant.d.cts +7 -0
- package/dist/{plan-BSa7k9HO.d.mts → plan-CT_6Y1fD.d.cts} +11 -9
- package/dist/plan-Cm9hCord.d.mts +101 -0
- package/dist/plan.d.cts +2 -0
- package/dist/plan.d.mts +2 -2
- package/dist/plan.mjs +145 -11
- package/dist/shared-LK3nImWW.mjs +55 -0
- package/dist/testing.d.cts +3242 -0
- package/dist/testing.d.mts +25 -19
- package/dist/testing.mjs +2140 -3939
- package/package.json +35 -32
- package/src/client/chat-node/MessageIconActions.styles.ts +76 -0
- package/src/client/chat-node/MessageIconActions.tsx +30 -11
- package/src/client/chat-node/MessageItem.styles.ts +247 -0
- package/src/client/chat-node/MessageItem.tsx +32 -39
- package/src/client/chat-node/message-chrome.ts +0 -2
- package/src/client/chat-node/register.ts +3 -4
- package/src/client/chat-node/use-calendar-day.ts +0 -4
- package/src/client/composer-stats/StatsPills.styles.ts +57 -0
- package/src/client/composer-stats/StatsPills.tsx +384 -0
- package/src/client/composer-stats/register.ts +17 -0
- package/src/client/composer-stats/stat-dialog.styles.ts +70 -0
- package/src/client/composer-stats/stat-dialog.ts +52 -0
- package/src/client/composer-stats/token-format.ts +85 -0
- package/src/client/composer-stats/turn-reading.ts +35 -0
- package/src/client/controller.ts +32 -114
- package/src/client/index.ts +8 -24
- package/src/index.ts +102 -111
- package/src/plan.ts +189 -24
- package/src/shared.ts +1 -1
- package/src/testing.ts +1 -5
- package/src/types.ts +1 -1
- package/dist/client.js +0 -806
- package/dist/src-B4HwXsob.mjs +0 -499
- package/src/client/chat-node/MessageIconActions.module.css +0 -86
- package/src/client/chat-node/MessageItem.module.css +0 -290
- package/src/client/css-modules.d.ts +0 -4
- package/src/client/import-action.tsx +0 -74
package/src/index.ts
CHANGED
|
@@ -33,10 +33,12 @@ import {
|
|
|
33
33
|
} from "./shared.ts";
|
|
34
34
|
import {
|
|
35
35
|
closedTurns,
|
|
36
|
+
droppedCompactions,
|
|
36
37
|
editableMessages,
|
|
37
38
|
editPlan,
|
|
38
39
|
precedingContentIndex,
|
|
39
40
|
recallBoundary,
|
|
41
|
+
restoreCompactions,
|
|
40
42
|
retryPlan,
|
|
41
43
|
rerollPlan,
|
|
42
44
|
retryableTurns,
|
|
@@ -69,9 +71,9 @@ declare module "@deepseek-ai/cordis" {
|
|
|
69
71
|
export interface EditorAgent {
|
|
70
72
|
readonly session: Session;
|
|
71
73
|
followup(message: UserMessage): void;
|
|
72
|
-
|
|
74
|
+
|
|
73
75
|
whenIdle(): Promise<void>;
|
|
74
|
-
|
|
76
|
+
|
|
75
77
|
cancel?(cause: AgentCancelCause, options?: { keepInbox?: boolean }): void;
|
|
76
78
|
}
|
|
77
79
|
|
|
@@ -154,23 +156,25 @@ function appendManualTurn(
|
|
|
154
156
|
});
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
// 边界事件的保留语义(与 session-branch 的 rewind 一致):turn/end 保留边界事件本身,user/message 丢掉它自己。
|
|
160
|
+
function keepFromOf(events: readonly SessionEvent[], boundary: number): number {
|
|
161
|
+
if (boundary < 0) return 0;
|
|
162
|
+
return events[boundary]?.type === "turn/end" ? boundary + 1 : boundary;
|
|
163
|
+
}
|
|
164
|
+
|
|
157
165
|
async function appendSeedSuffixLive(
|
|
158
166
|
session: Session,
|
|
159
167
|
seedSuffix: readonly SessionEvent[],
|
|
160
168
|
appendDirect: (events: readonly SessionEvent[]) => Promise<void>,
|
|
161
169
|
): Promise<void> {
|
|
162
170
|
for (const event of seedSuffix) {
|
|
163
|
-
// 版本效果事件携带 ignorable 标记(上游类型无此字段,duck-type 读取)。
|
|
164
171
|
const ignorable = (event as { ignorable?: boolean }).ignorable === true;
|
|
165
172
|
if (ignorable) {
|
|
166
173
|
const s = session as unknown as {
|
|
167
174
|
log: SessionEvent[];
|
|
168
175
|
eventsSnapshot?: unknown;
|
|
169
176
|
};
|
|
170
|
-
|
|
171
|
-
// ignorable 信封,与 JSONL 一致)。session.append 不保留 ignorable
|
|
172
|
-
// 标记且类型系统禁止 append 未知类型,因此不发布、直接 push 内存
|
|
173
|
-
// log(seq 按 log 续接重编号,与 handle cursor 同空间)。
|
|
177
|
+
|
|
174
178
|
const seq = s.log.length;
|
|
175
179
|
await appendDirect([{ ...event, seq } as SessionEvent]);
|
|
176
180
|
s.log.push({ ...event, seq } as SessionEvent);
|
|
@@ -204,8 +208,7 @@ export class SessionEditor extends Service {
|
|
|
204
208
|
|
|
205
209
|
constructor(ctx: Context) {
|
|
206
210
|
super(ctx, "sessionEditor");
|
|
207
|
-
|
|
208
|
-
// bundles 顺序保证 webserver 先于本类实例化。
|
|
211
|
+
|
|
209
212
|
registerHttpRoutes(ctx);
|
|
210
213
|
}
|
|
211
214
|
|
|
@@ -287,14 +290,12 @@ export class SessionEditor extends Service {
|
|
|
287
290
|
: operation.action === "retry"
|
|
288
291
|
? retryPlan(operation, turns)
|
|
289
292
|
: rerollPlan(operation, turns);
|
|
290
|
-
|
|
291
|
-
// (编辑第一轮 boundary = -1 清空全部),重放 agent 需要 provider/model。
|
|
293
|
+
|
|
292
294
|
const headerConfig = events.findLast((event) => event.type === "request/header")?.data.header
|
|
293
295
|
.config;
|
|
294
296
|
|
|
295
297
|
const turnIndex = turns.findIndex((turn) => turn.startSeq === plan.anchorSeq);
|
|
296
|
-
|
|
297
|
-
// 重放按保留前缀续号——轮次导航不再出现点不开的空项与轮号空洞。
|
|
298
|
+
|
|
298
299
|
const preceding = precedingContentIndex(turns, turnIndex);
|
|
299
300
|
const boundary =
|
|
300
301
|
plan.rewindBoundary !== undefined
|
|
@@ -302,21 +303,19 @@ export class SessionEditor extends Service {
|
|
|
302
303
|
: preceding < 0
|
|
303
304
|
? -1
|
|
304
305
|
: turns[preceding]!.endSeq!;
|
|
305
|
-
//
|
|
306
|
-
|
|
306
|
+
// 补回被截断丢掉、但在重放目标之前的压缩
|
|
307
|
+
const keepFrom = keepFromOf(events, boundary);
|
|
308
|
+
const compactions = droppedCompactions(events, keepFrom, plan.targetSeq);
|
|
309
|
+
|
|
307
310
|
const replayTurn = preceding < 0 ? 1 : turns[preceding]!.turn + 1;
|
|
308
311
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
appendLogSeedEvent(seedSuffix, "session-branch/version", plan.version, true);
|
|
312
|
+
const versionSeed: SessionEvent[] = [];
|
|
313
|
+
appendLogSeedEvent(versionSeed, "session-branch/version", plan.version, true);
|
|
314
|
+
const manualSeed: SessionEvent[] = [];
|
|
313
315
|
if (plan.manualTurn !== undefined) {
|
|
314
|
-
appendManualTurn(
|
|
316
|
+
appendManualTurn(manualSeed, { ...plan.manualTurn, turn: replayTurn });
|
|
315
317
|
}
|
|
316
318
|
|
|
317
|
-
// 就地编辑:不创建新会话、不改变 id。需要重放排队输入时,先在 rewind
|
|
318
|
-
// 前确保 agent 就绪(cold 先 resume;live 复用驻留 agent)——rewind 截断后
|
|
319
|
-
// agent 无法再以完整会话 resume,且重放失败不应让截断静默丢弃内容。
|
|
320
319
|
const replay = await this.prepareReplay(
|
|
321
320
|
operation.sessionId,
|
|
322
321
|
plan.queuedUsers,
|
|
@@ -324,64 +323,20 @@ export class SessionEditor extends Service {
|
|
|
324
323
|
headerConfig,
|
|
325
324
|
);
|
|
326
325
|
|
|
327
|
-
// rewind 前必须停止运行中的 loop:截断会重写 agent 的 session 内存 log。
|
|
328
326
|
await this.stopLoop(operation.sessionId, signal);
|
|
329
327
|
const live = this.ctx.sessions.get(operation.sessionId);
|
|
330
328
|
await this.ctx.sessionBranch.rewind(operation.sessionId, boundary, signal);
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
| {
|
|
340
|
-
append(events: readonly SessionEvent[]): Promise<void>;
|
|
341
|
-
}
|
|
342
|
-
| undefined;
|
|
343
|
-
};
|
|
344
|
-
};
|
|
345
|
-
const handle = persistence.tracker.writerOf(operation.sessionId);
|
|
346
|
-
if (handle === undefined) {
|
|
347
|
-
throw new Error(
|
|
348
|
-
`session "${operation.sessionId}" has no live write handle for the version effect`,
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
// rewind 截断后可能已追加事件(如 inbox 取消 splice),它们仍在
|
|
352
|
-
// write-behind 缓冲里;直接对 handle 写版本效果前必须先把缓冲落盘,
|
|
353
|
-
// 否则 handle cursor 落后于 log 长度 → append seq mismatch。
|
|
354
|
-
await this.ctx.sessions.flush(live);
|
|
355
|
-
await appendSeedSuffixLive(live, seedSuffix, (events) => handle.append(events));
|
|
356
|
-
await this.ctx.sessions.flush(live);
|
|
357
|
-
} else {
|
|
358
|
-
// cold:续写 seq 从平衡后的保留前缀接续(exclusive 截断可能残留
|
|
359
|
-
// 孤儿 step/start,落盘 log 对 token meter 重放非法)。
|
|
360
|
-
const rawKeepLength = boundary + (plan.rewindBoundary === undefined ? 1 : 0);
|
|
361
|
-
const keepLength = balanceRewindPrefix(events.slice(0, rawKeepLength)).length;
|
|
362
|
-
const renumbered = seedSuffix.map(
|
|
363
|
-
(event, index) =>
|
|
364
|
-
({
|
|
365
|
-
...event,
|
|
366
|
-
seq: keepLength + index,
|
|
367
|
-
}) as SessionEvent,
|
|
368
|
-
);
|
|
369
|
-
const handle = await this.ctx.sessionPersistence.open(operation.sessionId, "write");
|
|
370
|
-
try {
|
|
371
|
-
await handle.append(renumbered);
|
|
372
|
-
} finally {
|
|
373
|
-
await handle.close();
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
329
|
+
// 补回的压缩落在版本效果之后、重放的输入之前
|
|
330
|
+
const seedStart = this.seedStart(live, events, keepFrom);
|
|
331
|
+
const seedSuffix: SessionEvent[] = [
|
|
332
|
+
...versionSeed,
|
|
333
|
+
...restoreCompactions(compactions, keepFrom, seedStart + versionSeed.length),
|
|
334
|
+
...manualSeed,
|
|
335
|
+
];
|
|
336
|
+
await this.appendSeedSuffix(operation.sessionId, live, seedSuffix, events, keepFrom);
|
|
377
337
|
|
|
378
|
-
// 发起新的 user prompt:把排队输入交给就绪的 agent(rewind 后其 session
|
|
379
|
-
// 已被截断,followup 基于截断后历史开新轮重放)。无 agent(agents 服务
|
|
380
|
-
// 缺失)时退化为已 durable 的就地版本。
|
|
381
338
|
let queuedTurns = 0;
|
|
382
339
|
if (replay.agent !== undefined && plan.queuedUsers.length > 0) {
|
|
383
|
-
// seedSuffix 里的 manualTurn 直接写入了 turn/start,而 agent 的轮次
|
|
384
|
-
// 游标仍停在 rewind 重置的位置;不同步就会把重放开成重复轮号。
|
|
385
340
|
const lastSeedTurn = seedSuffix.findLast((event) => event.type === "turn/start")?.data.turn;
|
|
386
341
|
if (lastSeedTurn !== undefined) {
|
|
387
342
|
const phase = (replay.agent as unknown as { phase?: { lastTurn?: number } }).phase;
|
|
@@ -394,19 +349,11 @@ export class SessionEditor extends Service {
|
|
|
394
349
|
return {
|
|
395
350
|
sessionId: operation.sessionId,
|
|
396
351
|
queuedTurns,
|
|
397
|
-
|
|
352
|
+
|
|
398
353
|
live: this.ctx.sessions.get(operation.sessionId) !== undefined,
|
|
399
354
|
};
|
|
400
355
|
}
|
|
401
356
|
|
|
402
|
-
/**
|
|
403
|
-
* 撤回(recall):只 rewind 截断,不重写、不重放——被撤回的 user 消息文本
|
|
404
|
-
* 由客户端回填到输入框,交给用户修改后重新发送。
|
|
405
|
-
*
|
|
406
|
-
* 边界见 {@link recallBoundary}:轮首消息整轮截断到前一轮 `turn/end`
|
|
407
|
-
* (无前轮则 -1),轮内 followup 与轮外 user 消息只截断到该消息本身
|
|
408
|
-
* (exclusive drop),避免留下悬空 `turn/start` 让下一次发送开到错误轮号。
|
|
409
|
-
*/
|
|
410
357
|
private async recallOperation(
|
|
411
358
|
operation: RecallOperation,
|
|
412
359
|
signal?: AbortSignal,
|
|
@@ -414,9 +361,20 @@ export class SessionEditor extends Service {
|
|
|
414
361
|
signal?.throwIfAborted();
|
|
415
362
|
const events = await this.readEvents(operation.sessionId, signal);
|
|
416
363
|
const boundary = recallBoundary(events, closedTurns(events), operation.eventSeq);
|
|
417
|
-
//
|
|
364
|
+
// 撤回同样要补回被丢掉、但在撤回目标之前的压缩
|
|
365
|
+
const keepFrom = keepFromOf(events, boundary);
|
|
366
|
+
const compactions = droppedCompactions(events, keepFrom, operation.eventSeq);
|
|
367
|
+
|
|
418
368
|
await this.stopLoop(operation.sessionId, signal);
|
|
369
|
+
const live = this.ctx.sessions.get(operation.sessionId);
|
|
419
370
|
await this.ctx.sessionBranch.rewind(operation.sessionId, boundary, signal);
|
|
371
|
+
await this.appendSeedSuffix(
|
|
372
|
+
operation.sessionId,
|
|
373
|
+
live,
|
|
374
|
+
restoreCompactions(compactions, keepFrom, this.seedStart(live, events, keepFrom)),
|
|
375
|
+
events,
|
|
376
|
+
keepFrom,
|
|
377
|
+
);
|
|
420
378
|
return {
|
|
421
379
|
sessionId: operation.sessionId,
|
|
422
380
|
queuedTurns: 0,
|
|
@@ -424,9 +382,62 @@ export class SessionEditor extends Service {
|
|
|
424
382
|
};
|
|
425
383
|
}
|
|
426
384
|
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
|
|
385
|
+
// 种子后缀的起点 seq:live 会话是被截断后的日志长度,cold 会话是配平后的保留前缀长度
|
|
386
|
+
private seedStart(
|
|
387
|
+
live: Session | undefined,
|
|
388
|
+
events: readonly SessionEvent[],
|
|
389
|
+
keepFrom: number,
|
|
390
|
+
): number {
|
|
391
|
+
return live === undefined
|
|
392
|
+
? balanceRewindPrefix(events.slice(0, keepFrom)).length
|
|
393
|
+
: Number(live.seq);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private async appendSeedSuffix(
|
|
397
|
+
sessionId: SessionId,
|
|
398
|
+
live: Session | undefined,
|
|
399
|
+
seedSuffix: readonly SessionEvent[],
|
|
400
|
+
events: readonly SessionEvent[],
|
|
401
|
+
keepFrom: number,
|
|
402
|
+
): Promise<void> {
|
|
403
|
+
if (seedSuffix.length === 0) return;
|
|
404
|
+
if (live !== undefined) {
|
|
405
|
+
const persistence = this.ctx.sessionPersistence as unknown as {
|
|
406
|
+
tracker: {
|
|
407
|
+
writerOf(id: SessionId):
|
|
408
|
+
| {
|
|
409
|
+
append(events: readonly SessionEvent[]): Promise<void>;
|
|
410
|
+
}
|
|
411
|
+
| undefined;
|
|
412
|
+
};
|
|
413
|
+
};
|
|
414
|
+
const handle = persistence.tracker.writerOf(sessionId);
|
|
415
|
+
if (handle === undefined) {
|
|
416
|
+
throw new Error(`session "${sessionId}" has no live write handle for the version effect`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
await this.ctx.sessions.flush(live);
|
|
420
|
+
await appendSeedSuffixLive(live, seedSuffix, (batch) => handle.append(batch));
|
|
421
|
+
await this.ctx.sessions.flush(live);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const keepLength = balanceRewindPrefix(events.slice(0, keepFrom)).length;
|
|
426
|
+
const renumbered = seedSuffix.map(
|
|
427
|
+
(event, index) =>
|
|
428
|
+
({
|
|
429
|
+
...event,
|
|
430
|
+
seq: keepLength + index,
|
|
431
|
+
}) as SessionEvent,
|
|
432
|
+
);
|
|
433
|
+
const handle = await this.ctx.sessionPersistence.open(sessionId, "write");
|
|
434
|
+
try {
|
|
435
|
+
await handle.append(renumbered);
|
|
436
|
+
} finally {
|
|
437
|
+
await handle.close();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
430
441
|
private async stopLoop(sessionId: SessionId, signal?: AbortSignal): Promise<void> {
|
|
431
442
|
signal?.throwIfAborted();
|
|
432
443
|
const agents = this.ctx.get("agents") as EditorAgentRegistry | undefined;
|
|
@@ -443,8 +454,7 @@ export class SessionEditor extends Service {
|
|
|
443
454
|
): Promise<readonly SessionEvent[]> {
|
|
444
455
|
const live = this.ctx.sessions.get(sessionId);
|
|
445
456
|
if (live !== undefined) return live.snapshotEvents();
|
|
446
|
-
|
|
447
|
-
// 补成闭合,编辑未闭合轮次的 user 消息会走错边界。
|
|
457
|
+
|
|
448
458
|
const branch = this.ctx.sessionBranch as unknown as {
|
|
449
459
|
readRawEvents(
|
|
450
460
|
id: SessionId,
|
|
@@ -454,10 +464,6 @@ export class SessionEditor extends Service {
|
|
|
454
464
|
return (await branch.readRawEvents(sessionId, signal)).events;
|
|
455
465
|
}
|
|
456
466
|
|
|
457
|
-
// rewind 前确保 agent 可驱动重放:cold 会话先 resume 出驻留 agent(此时会话
|
|
458
|
-
// 完整,resume 的 prepare 不与截断冲突);live 会话复用驻留 agent,停止其
|
|
459
|
-
// 运行中的 loop 由 rewind 前的 stopLoop 统一负责。agents 服务缺失或无需
|
|
460
|
-
// 重放时返回空——调用方退化为就地截断版本。
|
|
461
467
|
private async prepareReplay(
|
|
462
468
|
sessionId: SessionId,
|
|
463
469
|
queuedUsers: readonly UserMessage[],
|
|
@@ -470,16 +476,12 @@ export class SessionEditor extends Service {
|
|
|
470
476
|
if (agents === undefined) return { agent: undefined };
|
|
471
477
|
const existing = agents.get(sessionId);
|
|
472
478
|
if (existing !== undefined) {
|
|
473
|
-
// 排队输入由 rewind 在截断后强制 durable 取消(session-rdb 的
|
|
474
|
-
// LiveSessionHooks.inbox.clear),这里只需复用驻留 agent。
|
|
475
479
|
return { agent: existing };
|
|
476
480
|
}
|
|
477
|
-
|
|
478
|
-
// resume 失败是硬错误:rewind 尚未发生,编辑保持原子(不截断不丢数据)。
|
|
481
|
+
|
|
479
482
|
const provider = headerConfig?.provider ?? "";
|
|
480
483
|
const model = headerConfig?.model ?? "";
|
|
481
484
|
if (provider.length === 0 || model.length === 0) {
|
|
482
|
-
// 兜底:从当前会话 events 解析(headerConfig 未提供时)。
|
|
483
485
|
const events = await this.readEvents(sessionId, signal);
|
|
484
486
|
const config = events.findLast((event) => event.type === "request/header")?.data.header
|
|
485
487
|
.config;
|
|
@@ -506,11 +508,6 @@ export class SessionEditor extends Service {
|
|
|
506
508
|
|
|
507
509
|
export default SessionEditor;
|
|
508
510
|
|
|
509
|
-
// ---------------------------------------------------------------------------
|
|
510
|
-
// HTTP 面(host):GET /session-editor(timeline 投影)/ POST /session-editor
|
|
511
|
-
// (edit | reroll | retry | rewind | fork)。
|
|
512
|
-
// ---------------------------------------------------------------------------
|
|
513
|
-
|
|
514
511
|
interface HttpRequestLike {
|
|
515
512
|
method?: string;
|
|
516
513
|
url?: string;
|
|
@@ -733,8 +730,6 @@ async function handleRoute(
|
|
|
733
730
|
}
|
|
734
731
|
|
|
735
732
|
function registerHttpRoutes(ctx: Context): void {
|
|
736
|
-
// web 模式:注册到 webServer(dsh web 的 HTTP 面)。服务在构造期可能
|
|
737
|
-
// 尚未就绪,effect 回调在插件激活后执行,此时 ctx.get 才能取到。
|
|
738
733
|
ctx.effect(() => {
|
|
739
734
|
const webServer = ctx.get("webServer") as HttpServerLike | undefined;
|
|
740
735
|
if (webServer === undefined) return () => {};
|
|
@@ -745,10 +740,7 @@ function registerHttpRoutes(ctx: Context): void {
|
|
|
745
740
|
handler: (request, response) => handleRoute(editor, request, response),
|
|
746
741
|
});
|
|
747
742
|
}, "session-editor: HTTP route");
|
|
748
|
-
|
|
749
|
-
// webserver,走 connection 网关;路径带 /api 前缀)。connection 是可选
|
|
750
|
-
// 服务(web 模式无),且可能在 SessionEditor 构造前已 provide(事件已
|
|
751
|
-
// 错过)——监听 internal/service 事件并立即检查一次。
|
|
743
|
+
|
|
752
744
|
const registerConnectionRoute = (): void => {
|
|
753
745
|
const connection = ctx.get("connection", false) as
|
|
754
746
|
| {
|
|
@@ -781,7 +773,6 @@ function registerHttpRoutes(ctx: Context): void {
|
|
|
781
773
|
registerConnectionRoute();
|
|
782
774
|
}
|
|
783
775
|
|
|
784
|
-
/** connection.fetch 路由的 Fetch 形态处理(与 webServer 的 node:http 形态同语义)。 */
|
|
785
776
|
async function handleFetchRoute(editor: SessionEditor, request: Request): Promise<Response> {
|
|
786
777
|
try {
|
|
787
778
|
if (request.method === "GET") {
|
package/src/plan.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { AssistantMessage, ContentBlock, UserMessage } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import { SessionSeq } from "@deepseek-ai/dsh-session";
|
|
3
4
|
import type { SessionEvent, SessionId } from "@deepseek-ai/dsh-session";
|
|
4
5
|
import {
|
|
5
6
|
SESSION_BRANCH_VERSION_SCHEMA,
|
|
@@ -24,7 +25,7 @@ export interface ClosedTurn {
|
|
|
24
25
|
|
|
25
26
|
closed: boolean;
|
|
26
27
|
user?: SessionEvent<"user/message">;
|
|
27
|
-
|
|
28
|
+
|
|
28
29
|
users: SessionEvent<"user/message">[];
|
|
29
30
|
assistants: SessionEvent<"assistant/message">[];
|
|
30
31
|
}
|
|
@@ -38,6 +39,9 @@ export interface OperationPlan {
|
|
|
38
39
|
manualTurn?: { turn: number; user: UserMessage; assistant: AssistantMessage };
|
|
39
40
|
|
|
40
41
|
queuedUsers: UserMessage[];
|
|
42
|
+
|
|
43
|
+
// 操作目标事件(被重放或撤回的那条)的 seq
|
|
44
|
+
targetSeq: number;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
function isTextualBlock(
|
|
@@ -88,8 +92,6 @@ export function closedTurns(events: readonly SessionEvent[]): ClosedTurn[] {
|
|
|
88
92
|
}
|
|
89
93
|
if (current === undefined) continue;
|
|
90
94
|
if (event.type === "user/message" && event.data.source.kind === "user") {
|
|
91
|
-
// 轮内全部 user/message 都记录:agent 运行中 followup 追加的输入
|
|
92
|
-
// (第二条及之后)也是已落定文本,可编辑。
|
|
93
95
|
if (current.user === undefined) current.user = event;
|
|
94
96
|
current.users.push(event);
|
|
95
97
|
continue;
|
|
@@ -103,8 +105,7 @@ export function closedTurns(events: readonly SessionEvent[]): ClosedTurn[] {
|
|
|
103
105
|
current = undefined;
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
|
-
|
|
107
|
-
// 消息(exclusive drop)重放即可。
|
|
108
|
+
|
|
108
109
|
if (current !== undefined) result.push({ ...current, closed: false });
|
|
109
110
|
return result;
|
|
110
111
|
}
|
|
@@ -126,7 +127,7 @@ export function editableMessages(turns: readonly ClosedTurn[]): EditableMessageB
|
|
|
126
127
|
});
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
|
-
|
|
130
|
+
|
|
130
131
|
if (!turn.closed) continue;
|
|
131
132
|
for (const event of turn.assistants) {
|
|
132
133
|
for (const [blockIndex, block] of event.data.message.content.entries()) {
|
|
@@ -148,7 +149,6 @@ export function editableMessages(turns: readonly ClosedTurn[]): EditableMessageB
|
|
|
148
149
|
|
|
149
150
|
export function retryableTurns(turns: readonly ClosedTurn[]): RetryableTurn[] {
|
|
150
151
|
return turns.flatMap((turn): RetryableTurn[] =>
|
|
151
|
-
// 未闭合轮次无已落定回复可重生成,不可重试。
|
|
152
152
|
turn.user === undefined || !turn.closed
|
|
153
153
|
? []
|
|
154
154
|
: [
|
|
@@ -162,12 +162,6 @@ export function retryableTurns(turns: readonly ClosedTurn[]): RetryableTurn[] {
|
|
|
162
162
|
);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
/**
|
|
166
|
-
* 目标轮之前最后一个「有内容」轮次的下标:跳过既无用户输入也无助手回复的
|
|
167
|
-
* 空轮。空轮是早期重放缺陷的遗留(turn/start 后直接 turn/end),在轨迹 /
|
|
168
|
-
* 轮次导航里表现为点不开的空项,并让后续轮号出现空洞。rewind 把它们随
|
|
169
|
-
* 目标轮一起截断,重放按保留前缀续号即可自愈。
|
|
170
|
-
*/
|
|
171
165
|
export function precedingContentIndex(turns: readonly ClosedTurn[], turnIndex: number): number {
|
|
172
166
|
let index = turnIndex - 1;
|
|
173
167
|
while (index >= 0) {
|
|
@@ -184,9 +178,6 @@ export function downstreamUsers(turns: readonly ClosedTurn[], start: number): Us
|
|
|
184
178
|
.flatMap((turn): UserMessage[] => turn.users.map((user) => cloneUser(user.data)));
|
|
185
179
|
}
|
|
186
180
|
|
|
187
|
-
// 撤回的 rewind 边界:轮内消息沿用 edit 的轮首规则(轮首整轮截断到前一轮
|
|
188
|
-
// turn/end,无前轮则 -1;轮内 followup 截断到该消息本身),轮外 user 消息按
|
|
189
|
-
// 消息自身位置 exclusive drop(截断该消息及其后全部)。
|
|
190
181
|
export function recallBoundary(
|
|
191
182
|
events: readonly SessionEvent[],
|
|
192
183
|
turns: readonly ClosedTurn[],
|
|
@@ -263,17 +254,13 @@ export function editPlan(operation: EditOperation, turns: readonly ClosedTurn[])
|
|
|
263
254
|
event.data,
|
|
264
255
|
replaceTextBlock(event.data.content, operation.blockIndex, operation.text),
|
|
265
256
|
);
|
|
266
|
-
|
|
267
|
-
// 输入:rewind 会 drop 它们,重放时保留(truncate 只截断回复,不丢输入)。
|
|
257
|
+
|
|
268
258
|
const userIndex = turn.users.findIndex((candidate) => candidate.seq === event.seq);
|
|
269
259
|
const sameTurnFollowups = turn.users.slice(userIndex + 1).map((user) => cloneUser(user.data));
|
|
270
260
|
const later = operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : [];
|
|
271
261
|
return {
|
|
272
262
|
anchorSeq: turn.startSeq,
|
|
273
|
-
|
|
274
|
-
// 目标轮号;未闭合轮同样整轮截断——保留悬空的 turn/start 会让重放
|
|
275
|
-
// 开到下一轮,轮号对不上。轮内 followup 只 rewind 到该消息本身
|
|
276
|
-
// (exclusive drop),保留轮内已落定的前置输入与回复。
|
|
263
|
+
|
|
277
264
|
...(userIndex === 0 ? {} : { rewindBoundary: event.seq }),
|
|
278
265
|
version: pairVersionEffect(operation.sessionId, {
|
|
279
266
|
operation: "edit",
|
|
@@ -286,10 +273,10 @@ export function editPlan(operation: EditOperation, turns: readonly ClosedTurn[])
|
|
|
286
273
|
after: operation.text,
|
|
287
274
|
}),
|
|
288
275
|
queuedUsers: [edited, ...sameTurnFollowups, ...later],
|
|
276
|
+
targetSeq: event.seq,
|
|
289
277
|
};
|
|
290
278
|
}
|
|
291
279
|
|
|
292
|
-
// 未闭合轮次的助手消息是流式 partial,无最终内容可编辑。
|
|
293
280
|
if (!turn.closed)
|
|
294
281
|
throw new SessionBranchError("未闭合轮次的助手消息不可编辑。", "INVALID_BOUNDARY");
|
|
295
282
|
const before = event.data.message.content[operation.blockIndex];
|
|
@@ -317,6 +304,7 @@ export function editPlan(operation: EditOperation, turns: readonly ClosedTurn[])
|
|
|
317
304
|
assistant: assistantReplacement(event, operation.blockIndex, operation.text),
|
|
318
305
|
},
|
|
319
306
|
queuedUsers: operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : [],
|
|
307
|
+
targetSeq: event.seq,
|
|
320
308
|
};
|
|
321
309
|
}
|
|
322
310
|
|
|
@@ -337,6 +325,7 @@ export function retryPlan(operation: RetryOperation, turns: readonly ClosedTurn[
|
|
|
337
325
|
operation.cascade === "preserve"
|
|
338
326
|
? downstreamUsers(turns, turnIndex)
|
|
339
327
|
: turn.users.map((user) => cloneUser(user.data)),
|
|
328
|
+
targetSeq: turn.user.seq,
|
|
340
329
|
};
|
|
341
330
|
}
|
|
342
331
|
|
|
@@ -346,7 +335,7 @@ export function rerollPlan(
|
|
|
346
335
|
): OperationPlan {
|
|
347
336
|
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
348
337
|
const turn = turns[index];
|
|
349
|
-
|
|
338
|
+
|
|
350
339
|
if (turn?.user === undefined || !turn.closed) continue;
|
|
351
340
|
const target = turn.assistants.findLast((event) =>
|
|
352
341
|
event.data.message.content.some(isTextualBlock),
|
|
@@ -361,7 +350,183 @@ export function rerollPlan(
|
|
|
361
350
|
targetEventSeq: target.seq,
|
|
362
351
|
}),
|
|
363
352
|
queuedUsers: turn.users.map((user) => cloneUser(user.data)),
|
|
353
|
+
targetSeq: target.seq,
|
|
364
354
|
};
|
|
365
355
|
}
|
|
366
356
|
throw new SessionBranchError("当前会话没有可重生成的已落定助手回复。", "INVALID_BOUNDARY");
|
|
367
357
|
}
|
|
358
|
+
|
|
359
|
+
export interface CompactionBracket {
|
|
360
|
+
start: SessionEvent;
|
|
361
|
+
summary?: SessionEvent;
|
|
362
|
+
checkpoint: SessionEvent;
|
|
363
|
+
end: SessionEvent;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const COMPACTION_TYPES: ReadonlySet<string> = new Set([
|
|
367
|
+
"compaction/start",
|
|
368
|
+
"compaction/summary",
|
|
369
|
+
"compaction/end",
|
|
370
|
+
]);
|
|
371
|
+
|
|
372
|
+
// 本包不加载 compaction 的事件类型增强,按类型字符串与结构性字段读取(与 session-rdb 的 schema、log 同一处理方式)。
|
|
373
|
+
function compactionTypeOf(event: SessionEvent): string | undefined {
|
|
374
|
+
return COMPACTION_TYPES.has(event.type) ? event.type : undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 压缩事务的替换消息:compact 插件写入的 surface replace 用户消息(checkpoint)。
|
|
378
|
+
function isCompactionCheckpoint(event: SessionEvent): boolean {
|
|
379
|
+
if (event.type !== "user/message") return false;
|
|
380
|
+
const op = (event as unknown as { surfaceOp?: unknown }).surfaceOp;
|
|
381
|
+
if (typeof op !== "object" || op === null) return false;
|
|
382
|
+
const source = event.data.source as unknown as { kind?: unknown; plugin?: unknown };
|
|
383
|
+
return source.kind === "plugin" && source.plugin === "compact";
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function compactionBrackets(events: readonly SessionEvent[]): CompactionBracket[] {
|
|
387
|
+
const brackets: CompactionBracket[] = [];
|
|
388
|
+
let open: { start: SessionEvent; summary?: SessionEvent; checkpoint?: SessionEvent } | undefined;
|
|
389
|
+
for (const event of events) {
|
|
390
|
+
const type = compactionTypeOf(event);
|
|
391
|
+
if (type === "compaction/start") {
|
|
392
|
+
open = { start: event };
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (open === undefined) continue;
|
|
396
|
+
if (type === "compaction/summary") {
|
|
397
|
+
open.summary = event;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (type === "compaction/end") {
|
|
401
|
+
if (open.checkpoint !== undefined) {
|
|
402
|
+
brackets.push({
|
|
403
|
+
start: open.start,
|
|
404
|
+
...(open.summary === undefined ? {} : { summary: open.summary }),
|
|
405
|
+
checkpoint: open.checkpoint,
|
|
406
|
+
end: event,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
open = undefined;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (isCompactionCheckpoint(event)) open.checkpoint = event;
|
|
413
|
+
}
|
|
414
|
+
return brackets;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// 截断丢掉、但仍在重放目标之前的完整压缩事务。
|
|
418
|
+
export function droppedCompactions(
|
|
419
|
+
events: readonly SessionEvent[],
|
|
420
|
+
keepFrom: number,
|
|
421
|
+
targetSeq: number,
|
|
422
|
+
): CompactionBracket[] {
|
|
423
|
+
return compactionBrackets(events).filter(
|
|
424
|
+
(bracket) => bracket.start.seq >= keepFrom && bracket.end.seq < targetSeq,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function replacementRange(event: SessionEvent): { start: number; end: number } | undefined {
|
|
429
|
+
const op = (
|
|
430
|
+
event as unknown as { surfaceOp?: { op?: unknown; startSeq?: unknown; endSeq?: unknown } }
|
|
431
|
+
).surfaceOp;
|
|
432
|
+
if (op?.op !== "replace") return undefined;
|
|
433
|
+
if (typeof op.startSeq !== "number" || typeof op.endSeq !== "number") return undefined;
|
|
434
|
+
return { start: op.startSeq, end: op.endSeq };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function numberList(value: unknown): number[] {
|
|
438
|
+
return Array.isArray(value)
|
|
439
|
+
? value.filter((item): item is number => typeof item === "number")
|
|
440
|
+
: [];
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 把截断丢掉的压缩重排到 base 起的连续 seq:standalone 形态(不属于任何 turn,与手动 /compact 一致),
|
|
444
|
+
// 摘要替换引用的 seq 同步重编号(保留前缀内的 seq 不变),metering 事件与替换消息保持相邻。
|
|
445
|
+
export function restoreCompactions(
|
|
446
|
+
brackets: readonly CompactionBracket[],
|
|
447
|
+
keepFrom: number,
|
|
448
|
+
base: number,
|
|
449
|
+
): SessionEvent[] {
|
|
450
|
+
const moved = new Map<number, number>();
|
|
451
|
+
const resolve = (seq: number): number | undefined => (seq < keepFrom ? seq : moved.get(seq));
|
|
452
|
+
const resolveAll = (seqs: readonly number[]): number[] | undefined => {
|
|
453
|
+
const resolved: number[] = [];
|
|
454
|
+
for (const seq of seqs) {
|
|
455
|
+
const next = resolve(seq);
|
|
456
|
+
if (next === undefined) return undefined;
|
|
457
|
+
resolved.push(next);
|
|
458
|
+
}
|
|
459
|
+
return resolved;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const restored: SessionEvent[] = [];
|
|
463
|
+
for (const bracket of brackets) {
|
|
464
|
+
const range = replacementRange(bracket.checkpoint);
|
|
465
|
+
const shadowedSeqs = resolveAll(
|
|
466
|
+
numberList((bracket.summary?.data as { shadowedSeqs?: unknown } | undefined)?.shadowedSeqs),
|
|
467
|
+
);
|
|
468
|
+
const start = range === undefined ? undefined : resolve(range.start);
|
|
469
|
+
const end = range === undefined ? undefined : resolve(range.end);
|
|
470
|
+
// 引用了被丢弃事件的压缩无法重建替换范围,只能一并丢弃
|
|
471
|
+
if (start === undefined || end === undefined || shadowedSeqs === undefined) continue;
|
|
472
|
+
|
|
473
|
+
const at = base + restored.length;
|
|
474
|
+
const summary = bracket.summary;
|
|
475
|
+
const checkpointSeq = at + (summary === undefined ? 1 : 2);
|
|
476
|
+
moved.set(bracket.start.seq, at);
|
|
477
|
+
if (summary !== undefined) moved.set(summary.seq, at + 1);
|
|
478
|
+
moved.set(bracket.checkpoint.seq, checkpointSeq);
|
|
479
|
+
moved.set(bracket.end.seq, checkpointSeq + 1);
|
|
480
|
+
|
|
481
|
+
// 引用自身事务的事件按新位置重算:开标记、metering 事件、替换范围内的 surface 节点
|
|
482
|
+
const sources = [at, ...(summary === undefined ? [] : [at + 1]), ...shadowedSeqs];
|
|
483
|
+
restored.push(
|
|
484
|
+
standaloneCompaction(bracket.start, at),
|
|
485
|
+
...(summary === undefined
|
|
486
|
+
? []
|
|
487
|
+
: [meteringShadowPrice(summary, at + 1, { start, end }, shadowedSeqs)]),
|
|
488
|
+
replacementCheckpoint(bracket.checkpoint, checkpointSeq, { start, end }, sources),
|
|
489
|
+
standaloneCompaction(bracket.end, checkpointSeq + 1),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
return restored;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function standaloneCompaction(event: SessionEvent, seq: number): SessionEvent {
|
|
496
|
+
return {
|
|
497
|
+
...event,
|
|
498
|
+
seq: SessionSeq(seq),
|
|
499
|
+
data: { ...event.data, turn: null },
|
|
500
|
+
} as unknown as SessionEvent;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function meteringShadowPrice(
|
|
504
|
+
event: SessionEvent,
|
|
505
|
+
seq: number,
|
|
506
|
+
range: { start: number; end: number },
|
|
507
|
+
shadowedSeqs: readonly number[],
|
|
508
|
+
): SessionEvent {
|
|
509
|
+
return {
|
|
510
|
+
...event,
|
|
511
|
+
seq: SessionSeq(seq),
|
|
512
|
+
data: {
|
|
513
|
+
...event.data,
|
|
514
|
+
shadowedRange: { start: range.start, end: range.end },
|
|
515
|
+
shadowedSeqs: [...shadowedSeqs],
|
|
516
|
+
},
|
|
517
|
+
} as unknown as SessionEvent;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function replacementCheckpoint(
|
|
521
|
+
event: SessionEvent,
|
|
522
|
+
seq: number,
|
|
523
|
+
range: { start: number; end: number },
|
|
524
|
+
sources: readonly number[],
|
|
525
|
+
): SessionEvent {
|
|
526
|
+
return {
|
|
527
|
+
...event,
|
|
528
|
+
seq: SessionSeq(seq),
|
|
529
|
+
surfaceOp: { op: "replace", startSeq: range.start, endSeq: range.end },
|
|
530
|
+
sourceEventSeqs: [...sources],
|
|
531
|
+
} as unknown as SessionEvent;
|
|
532
|
+
}
|