@morlay/ui-conversation-message-actions 0.0.14-alpha.1 → 0.0.14-alpha.4
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 +12 -1
- package/dist/client.js +39 -27
- package/dist/index.d.mts +2 -10
- package/dist/index.mjs +1 -1
- package/dist/{plan-Bw4zoI4N.d.mts → plan-BdU_mnTv.d.mts} +9 -2
- package/dist/plan.d.mts +2 -2
- package/dist/plan.mjs +17 -2
- package/dist/{src-Da2ZVKlq.mjs → src-ks167MhO.mjs} +54 -8
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1293 -233
- package/package.json +19 -19
- package/src/client/chat-node/MessageIconActions.tsx +4 -2
- package/src/client/controller.ts +21 -2
- package/src/index.ts +100 -24
- package/src/plan.ts +20 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morlay/ui-conversation-message-actions",
|
|
3
|
-
"version": "0.0.14-alpha.
|
|
3
|
+
"version": "0.0.14-alpha.4",
|
|
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",
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"./client": "./dist/client.js"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@morlay/session-branch": "^0.0.4-alpha.
|
|
38
|
+
"@morlay/session-branch": "^0.0.4-alpha.4"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@morlay/session-rdb": "^0.0.16-alpha.
|
|
41
|
+
"@morlay/session-rdb": "^0.0.16-alpha.4",
|
|
42
42
|
"@types/react": "^19.2.18",
|
|
43
43
|
"@types/react-dom": "^19.2.5",
|
|
44
44
|
"lightningcss": "^1.32.0",
|
|
@@ -47,22 +47,22 @@
|
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
50
|
-
"@deepseek-ai/dsh-api-session-controller": "^0.1.
|
|
51
|
-
"@deepseek-ai/dsh-attachment": "^0.1.
|
|
52
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.
|
|
53
|
-
"@deepseek-ai/dsh-client-store": "^0.1.
|
|
54
|
-
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.
|
|
55
|
-
"@deepseek-ai/dsh-client-ui-chat": "^0.1.
|
|
56
|
-
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.
|
|
57
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.
|
|
58
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.
|
|
59
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.
|
|
60
|
-
"@deepseek-ai/dsh-invariants": "^0.1.
|
|
61
|
-
"@deepseek-ai/dsh-llm": "^0.1.
|
|
62
|
-
"@deepseek-ai/dsh-session": "^0.1.
|
|
63
|
-
"@deepseek-ai/dsh-session-persistence": "^0.1.
|
|
64
|
-
"@deepseek-ai/dsh-session-projection": "^0.1.
|
|
65
|
-
"@deepseek-ai/dsh-token-meter": "^0.1.
|
|
50
|
+
"@deepseek-ai/dsh-api-session-controller": "^0.1.5-alpha.2",
|
|
51
|
+
"@deepseek-ai/dsh-attachment": "^0.1.5-alpha.2",
|
|
52
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.5-alpha.2",
|
|
53
|
+
"@deepseek-ai/dsh-client-store": "^0.1.5-alpha.2",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.5-alpha.2",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-chat": "^0.1.5-alpha.2",
|
|
56
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-alpha.2",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-alpha.2",
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-alpha.2",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-alpha.2",
|
|
60
|
+
"@deepseek-ai/dsh-invariants": "^0.1.5-alpha.2",
|
|
61
|
+
"@deepseek-ai/dsh-llm": "^0.1.5-alpha.2",
|
|
62
|
+
"@deepseek-ai/dsh-session": "^0.1.5-alpha.2",
|
|
63
|
+
"@deepseek-ai/dsh-session-persistence": "^0.1.5-alpha.2",
|
|
64
|
+
"@deepseek-ai/dsh-session-projection": "^0.1.5-alpha.2",
|
|
65
|
+
"@deepseek-ai/dsh-token-meter": "^0.1.5-alpha.2"
|
|
66
66
|
},
|
|
67
67
|
"dsh": {
|
|
68
68
|
"bundle": {
|
|
@@ -97,7 +97,8 @@ export function MessageIconActions({
|
|
|
97
97
|
}, [copied, text]);
|
|
98
98
|
// The dot is decorative and stays hidden, but its margins separate the
|
|
99
99
|
// readings only on screen: without the flanking spaces a reader hears one
|
|
100
|
-
// run-on string ("Ran for
|
|
100
|
+
// run-on string ("Ran for 13sTime to first token (TTFT) 0.2s12 tok/s")
|
|
101
|
+
// instead of three facts.
|
|
101
102
|
const clockEl =
|
|
102
103
|
time === undefined ? null : (
|
|
103
104
|
<span className={clock === "start" ? css.timeStart : css.timeEnd}>
|
|
@@ -117,7 +118,8 @@ export function MessageIconActions({
|
|
|
117
118
|
<span className={css.runTimeDot} aria-hidden>
|
|
118
119
|
·
|
|
119
120
|
</span>{" "}
|
|
120
|
-
{t("
|
|
121
|
+
{t("message.turnTime.ttft")}{" "}
|
|
122
|
+
{t("duration.seconds", { seconds: formatLatencySeconds(ttftMs) })}
|
|
121
123
|
</>
|
|
122
124
|
)}
|
|
123
125
|
{tokensPerSecond !== undefined && (
|
package/src/client/controller.ts
CHANGED
|
@@ -16,6 +16,13 @@ import {
|
|
|
16
16
|
type VersionOperation,
|
|
17
17
|
} from "../shared.ts";
|
|
18
18
|
|
|
19
|
+
/** desktop 壳(官方 dsh-desktop-host)注入的 transport 标记:ownsHost 时 API 走 /api 前缀。 */
|
|
20
|
+
function editorApiPath(): string {
|
|
21
|
+
const transport = (globalThis as { __DSH_TRANSPORT__?: { ownsHost?: boolean } })
|
|
22
|
+
.__DSH_TRANSPORT__;
|
|
23
|
+
return transport?.ownsHost === true ? `/api${SESSION_EDITOR_PATH}` : SESSION_EDITOR_PATH;
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
export interface SessionEditorState {
|
|
20
27
|
status: "idle" | "loading" | "ready" | "error";
|
|
21
28
|
error: string | null;
|
|
@@ -157,7 +164,7 @@ export class SessionEditorController {
|
|
|
157
164
|
});
|
|
158
165
|
try {
|
|
159
166
|
const response = await fetch(
|
|
160
|
-
`${
|
|
167
|
+
`${editorApiPath()}?sessionId=${encodeURIComponent(this.sessionId)}`,
|
|
161
168
|
{
|
|
162
169
|
method: "GET",
|
|
163
170
|
headers: { accept: "application/json" },
|
|
@@ -199,7 +206,7 @@ export class SessionEditorController {
|
|
|
199
206
|
state.error = null;
|
|
200
207
|
});
|
|
201
208
|
try {
|
|
202
|
-
const response = await fetch(
|
|
209
|
+
const response = await fetch(editorApiPath(), {
|
|
203
210
|
method: "POST",
|
|
204
211
|
headers: { accept: "application/json", "content-type": "application/json" },
|
|
205
212
|
body: JSON.stringify(operation),
|
|
@@ -229,6 +236,18 @@ export class SessionEditorController {
|
|
|
229
236
|
if (resync !== undefined) {
|
|
230
237
|
try {
|
|
231
238
|
await resync.call(face);
|
|
239
|
+
// resync 只重建事件窗口;投影 store 按 higher-seq-wins 保留 rewind
|
|
240
|
+
// 前的高 seq 旧值(截断后的正确值 seq 更小,永远覆盖不上),轮次
|
|
241
|
+
// 导航会残留已删除的轮次。丢弃全部投影行,等 host 推送重建;拿不到
|
|
242
|
+
// 丢弃入口时整页重载兜底。
|
|
243
|
+
const projections = (
|
|
244
|
+
face as unknown as { projections?: { truncate?(lastSeq: number): void } }
|
|
245
|
+
).projections;
|
|
246
|
+
if (typeof projections?.truncate !== "function") {
|
|
247
|
+
location.reload();
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
projections.truncate(-1);
|
|
232
251
|
void this.load();
|
|
233
252
|
return true;
|
|
234
253
|
} catch {
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Service, type Context } from "@deepseek-ai/cordis";
|
|
2
2
|
import type { AssistantMessage, UserMessage } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import { SessionSeq } from "@deepseek-ai/dsh-session";
|
|
3
4
|
import type {
|
|
4
5
|
Session,
|
|
5
6
|
SessionEvent,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
closedTurns,
|
|
33
34
|
editableMessages,
|
|
34
35
|
editPlan,
|
|
36
|
+
precedingContentIndex,
|
|
35
37
|
retryPlan,
|
|
36
38
|
rerollPlan,
|
|
37
39
|
retryableTurns,
|
|
@@ -65,14 +67,6 @@ export interface EditorAgent {
|
|
|
65
67
|
followup(message: UserMessage): void;
|
|
66
68
|
/** 等待 agent 到达 quiescence(当前 turn/任务结束后 resolve)。 */
|
|
67
69
|
whenIdle(): Promise<void>;
|
|
68
|
-
/**
|
|
69
|
-
* 队列中是否有待处理输入(next-turn / next-step)。rewind 截断会删除这些
|
|
70
|
-
* 输入所属轮次的事件,残留的 inbox 状态会与截断后的 log 失配,使后续
|
|
71
|
-
* splice 落库后无法从日志重放——编辑前必须清空。
|
|
72
|
-
*/
|
|
73
|
-
readonly inboxPending: boolean;
|
|
74
|
-
/** 清空待处理输入(落库 canceled splice,须在 rewind 前调用)。 */
|
|
75
|
-
clearInbox(): void;
|
|
76
70
|
}
|
|
77
71
|
|
|
78
72
|
export interface EditorAgentHandle {
|
|
@@ -123,12 +117,12 @@ function appendSurfaceSeedEvent<T extends SurfaceEventType>(
|
|
|
123
117
|
): void {
|
|
124
118
|
events.push({
|
|
125
119
|
type,
|
|
126
|
-
seq: events.length,
|
|
120
|
+
seq: SessionSeq(events.length),
|
|
127
121
|
time: Date.now(),
|
|
128
122
|
data,
|
|
129
123
|
surfaceOp: intent.surfaceOp,
|
|
130
124
|
...(intent.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: intent.sourceEventSeqs }),
|
|
131
|
-
} as SessionEvent<T>);
|
|
125
|
+
} as unknown as SessionEvent<T>);
|
|
132
126
|
}
|
|
133
127
|
|
|
134
128
|
function appendManualTurn(
|
|
@@ -287,11 +281,27 @@ export class SessionEditor extends Service {
|
|
|
287
281
|
const headerConfig = events.findLast((event) => event.type === "request/header")?.data.header
|
|
288
282
|
.config;
|
|
289
283
|
|
|
284
|
+
const turnIndex = turns.findIndex((turn) => turn.startSeq === plan.anchorSeq);
|
|
285
|
+
// 空轮吸收:目标轮之前的空轮(早期重放缺陷的遗留)随截断一并删除,
|
|
286
|
+
// 重放按保留前缀续号——轮次导航不再出现点不开的空项与轮号空洞。
|
|
287
|
+
const preceding = precedingContentIndex(turns, turnIndex);
|
|
288
|
+
const boundary =
|
|
289
|
+
plan.rewindBoundary !== undefined
|
|
290
|
+
? plan.rewindBoundary
|
|
291
|
+
: preceding < 0
|
|
292
|
+
? -1
|
|
293
|
+
: turns[preceding]!.endSeq!;
|
|
294
|
+
// manualTurn 的轮号同样按保留前缀续号:正常会话等于目标轮号,有空轮
|
|
295
|
+
// 时收敛为连续编号。
|
|
296
|
+
const replayTurn = preceding < 0 ? 1 : turns[preceding]!.turn + 1;
|
|
297
|
+
|
|
290
298
|
// 派生 seed 后缀:版本效果 + 可选手工回合。版本事件对核心是 ignorable,
|
|
291
299
|
// 保证非 branch 读者可安全跳过。
|
|
292
300
|
const seedSuffix: SessionEvent[] = [];
|
|
293
301
|
appendLogSeedEvent(seedSuffix, "session-branch/version", plan.version, true);
|
|
294
|
-
if (plan.manualTurn !== undefined)
|
|
302
|
+
if (plan.manualTurn !== undefined) {
|
|
303
|
+
appendManualTurn(seedSuffix, { ...plan.manualTurn, turn: replayTurn });
|
|
304
|
+
}
|
|
295
305
|
|
|
296
306
|
// 就地编辑:不创建新会话、不改变 id。需要重放排队输入时,先在 rewind
|
|
297
307
|
// 前确保 agent 就绪(live agent 等其停;cold 先 resume)——rewind 截断后
|
|
@@ -303,13 +313,6 @@ export class SessionEditor extends Service {
|
|
|
303
313
|
headerConfig,
|
|
304
314
|
);
|
|
305
315
|
|
|
306
|
-
const turnIndex = turns.findIndex((turn) => turn.startSeq === plan.anchorSeq);
|
|
307
|
-
const boundary =
|
|
308
|
-
plan.rewindBoundary !== undefined
|
|
309
|
-
? plan.rewindBoundary
|
|
310
|
-
: turnIndex <= 0
|
|
311
|
-
? -1
|
|
312
|
-
: turns[turnIndex - 1]!.endSeq!;
|
|
313
316
|
const live = this.ctx.sessions.get(operation.sessionId);
|
|
314
317
|
await this.ctx.sessionBranch.rewind(operation.sessionId, boundary, signal);
|
|
315
318
|
if (seedSuffix.length > 0) {
|
|
@@ -332,6 +335,10 @@ export class SessionEditor extends Service {
|
|
|
332
335
|
`session "${operation.sessionId}" has no live write handle for the version effect`,
|
|
333
336
|
);
|
|
334
337
|
}
|
|
338
|
+
// rewind 截断后可能已追加事件(如 inbox 取消 splice),它们仍在
|
|
339
|
+
// write-behind 缓冲里;直接对 handle 写版本效果前必须先把缓冲落盘,
|
|
340
|
+
// 否则 handle cursor 落后于 log 长度 → append seq mismatch。
|
|
341
|
+
await this.ctx.sessions.flush(live);
|
|
335
342
|
await appendSeedSuffixLive(live, seedSuffix, (events) => handle.append(events));
|
|
336
343
|
await this.ctx.sessions.flush(live);
|
|
337
344
|
} else {
|
|
@@ -360,6 +367,13 @@ export class SessionEditor extends Service {
|
|
|
360
367
|
// 缺失)时退化为已 durable 的就地版本。
|
|
361
368
|
let queuedTurns = 0;
|
|
362
369
|
if (replay.agent !== undefined && plan.queuedUsers.length > 0) {
|
|
370
|
+
// seedSuffix 里的 manualTurn 直接写入了 turn/start,而 agent 的轮次
|
|
371
|
+
// 游标仍停在 rewind 重置的位置;不同步就会把重放开成重复轮号。
|
|
372
|
+
const lastSeedTurn = seedSuffix.findLast((event) => event.type === "turn/start")?.data.turn;
|
|
373
|
+
if (lastSeedTurn !== undefined) {
|
|
374
|
+
const phase = (replay.agent as unknown as { phase?: { lastTurn?: number } }).phase;
|
|
375
|
+
if (phase !== undefined) phase.lastTurn = lastSeedTurn;
|
|
376
|
+
}
|
|
363
377
|
for (const message of plan.queuedUsers) replay.agent.followup(message);
|
|
364
378
|
await this.ctx.sessions.flush(replay.agent.session);
|
|
365
379
|
queuedTurns = plan.queuedUsers.length;
|
|
@@ -409,10 +423,8 @@ export class SessionEditor extends Service {
|
|
|
409
423
|
if (existing !== undefined) {
|
|
410
424
|
await existing.whenIdle();
|
|
411
425
|
signal?.throwIfAborted();
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
// 假设 log 只 append)。
|
|
415
|
-
if (existing.inboxPending) existing.clearInbox();
|
|
426
|
+
// 排队输入由 rewind 在截断后强制 durable 取消(session-rdb 的
|
|
427
|
+
// LiveSessionHooks.inbox.clear),这里只需保证 agent 已停下。
|
|
416
428
|
return { agent: existing };
|
|
417
429
|
}
|
|
418
430
|
// cold:resume 已持久化会话(create 会因「已存在持久化日志」失败)。
|
|
@@ -660,9 +672,11 @@ async function handleRoute(
|
|
|
660
672
|
}
|
|
661
673
|
|
|
662
674
|
function registerHttpRoutes(ctx: Context): void {
|
|
663
|
-
|
|
664
|
-
|
|
675
|
+
// web 模式:注册到 webServer(dsh web 的 HTTP 面)。服务在构造期可能
|
|
676
|
+
// 尚未就绪,effect 回调在插件激活后执行,此时 ctx.get 才能取到。
|
|
665
677
|
ctx.effect(() => {
|
|
678
|
+
const webServer = ctx.get("webServer") as HttpServerLike | undefined;
|
|
679
|
+
if (webServer === undefined) return () => {};
|
|
666
680
|
const editor = ctx.sessionEditor;
|
|
667
681
|
return webServer.register({
|
|
668
682
|
kind: "exact",
|
|
@@ -670,4 +684,66 @@ function registerHttpRoutes(ctx: Context): void {
|
|
|
670
684
|
handler: (request, response) => handleRoute(editor, request, response),
|
|
671
685
|
});
|
|
672
686
|
}, "session-editor: HTTP route");
|
|
687
|
+
// desktop 模式:注册到 connection 的共享 /api 通道(官方 desktop 禁用
|
|
688
|
+
// webserver,走 connection 网关;路径带 /api 前缀)。connection 是可选
|
|
689
|
+
// 服务(web 模式无),且可能在 SessionEditor 构造前已 provide(事件已
|
|
690
|
+
// 错过)——监听 internal/service 事件并立即检查一次。
|
|
691
|
+
const registerConnectionRoute = (): void => {
|
|
692
|
+
const connection = ctx.get("connection", false) as
|
|
693
|
+
| {
|
|
694
|
+
fetch: {
|
|
695
|
+
register(route: {
|
|
696
|
+
path: string;
|
|
697
|
+
methods: readonly ("GET" | "HEAD" | "POST")[];
|
|
698
|
+
requestBody: "buffered" | "streaming";
|
|
699
|
+
fetch: (request: Request) => Promise<Response>;
|
|
700
|
+
}): () => Promise<void>;
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
| undefined;
|
|
704
|
+
if (connection === undefined) return;
|
|
705
|
+
const editor = ctx.sessionEditor;
|
|
706
|
+
ctx.effect(
|
|
707
|
+
() =>
|
|
708
|
+
connection.fetch.register({
|
|
709
|
+
path: `/api${SESSION_EDITOR_PATH}`,
|
|
710
|
+
methods: ["GET", "POST"],
|
|
711
|
+
requestBody: "buffered",
|
|
712
|
+
fetch: (request) => handleFetchRoute(editor, request),
|
|
713
|
+
}),
|
|
714
|
+
"session-editor: connection fetch route",
|
|
715
|
+
);
|
|
716
|
+
};
|
|
717
|
+
ctx.on("internal/service", (name) => {
|
|
718
|
+
if (name === "connection") registerConnectionRoute();
|
|
719
|
+
});
|
|
720
|
+
registerConnectionRoute();
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** connection.fetch 路由的 Fetch 形态处理(与 webServer 的 node:http 形态同语义)。 */
|
|
724
|
+
async function handleFetchRoute(editor: SessionEditor, request: Request): Promise<Response> {
|
|
725
|
+
try {
|
|
726
|
+
if (request.method === "GET") {
|
|
727
|
+
const url = new URL(request.url);
|
|
728
|
+
const sessionId = sessionIdOf(url.searchParams.get("sessionId"));
|
|
729
|
+
return jsonResponse(200, await readTimeline(editor, sessionId));
|
|
730
|
+
}
|
|
731
|
+
if (request.method === "POST") {
|
|
732
|
+
return jsonResponse(200, await runOperation(editor, decodeOperation(await request.json())));
|
|
733
|
+
}
|
|
734
|
+
return new Response(null, { status: 405 });
|
|
735
|
+
} catch (error: unknown) {
|
|
736
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
737
|
+
return jsonResponse(error instanceof TypeError ? 400 : 409, { error: message });
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function jsonResponse(status: number, value: unknown): Response {
|
|
742
|
+
return new Response(JSON.stringify(value), {
|
|
743
|
+
status,
|
|
744
|
+
headers: {
|
|
745
|
+
"content-type": "application/json; charset=utf-8",
|
|
746
|
+
"cache-control": "no-store",
|
|
747
|
+
},
|
|
748
|
+
});
|
|
673
749
|
}
|
package/src/plan.ts
CHANGED
|
@@ -162,6 +162,22 @@ 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
|
+
export function precedingContentIndex(turns: readonly ClosedTurn[], turnIndex: number): number {
|
|
172
|
+
let index = turnIndex - 1;
|
|
173
|
+
while (index >= 0) {
|
|
174
|
+
const turn = turns[index]!;
|
|
175
|
+
if (turn.users.length > 0 || turn.assistants.length > 0) break;
|
|
176
|
+
index -= 1;
|
|
177
|
+
}
|
|
178
|
+
return index;
|
|
179
|
+
}
|
|
180
|
+
|
|
165
181
|
export function downstreamUsers(turns: readonly ClosedTurn[], start: number): UserMessage[] {
|
|
166
182
|
return turns
|
|
167
183
|
.slice(start)
|
|
@@ -229,10 +245,11 @@ export function editPlan(operation: EditOperation, turns: readonly ClosedTurn[])
|
|
|
229
245
|
const later = operation.cascade === "preserve" ? downstreamUsers(turns, turnIndex + 1) : [];
|
|
230
246
|
return {
|
|
231
247
|
anchorSeq: turn.startSeq,
|
|
232
|
-
// 轮首 user
|
|
233
|
-
//
|
|
248
|
+
// 轮首 user 编辑:整轮截断重放(rewind 到前一轮 turn/end),重放复用
|
|
249
|
+
// 目标轮号;未闭合轮同样整轮截断——保留悬空的 turn/start 会让重放
|
|
250
|
+
// 开到下一轮,轮号对不上。轮内 followup 只 rewind 到该消息本身
|
|
234
251
|
// (exclusive drop),保留轮内已落定的前置输入与回复。
|
|
235
|
-
...(
|
|
252
|
+
...(userIndex === 0 ? {} : { rewindBoundary: event.seq }),
|
|
236
253
|
version: pairVersionEffect(operation.sessionId, {
|
|
237
254
|
operation: "edit",
|
|
238
255
|
cascade: operation.cascade,
|