@morlay/ui-conversation-message-actions 0.0.16 → 0.0.18
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 +19 -10
- package/dist/client.js +40 -40
- package/dist/index.d.mts +9 -13
- package/dist/index.mjs +1 -1
- package/dist/{plan-Cn3TPTa0.d.mts → plan-BSa7k9HO.d.mts} +3 -2
- package/dist/plan.d.mts +2 -2
- package/dist/plan.mjs +14 -1
- package/dist/{src-Dq1X3rOO.mjs → src-B4HwXsob.mjs} +13 -28
- package/dist/testing.d.mts +41 -54
- package/dist/testing.mjs +7432 -400
- package/package.json +4 -4
- package/src/client/chat-node/MessageItem.tsx +4 -2
- package/src/index.ts +25 -35
- package/src/plan.ts +25 -0
- package/src/shared.ts +2 -1
- package/src/testing.ts +6 -1
- package/src/types.ts +2 -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.18",
|
|
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.
|
|
38
|
+
"@morlay/session-branch": "^0.0.8"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@morlay/session-rdb": "^0.0.
|
|
41
|
+
"@morlay/session-rdb": "^0.0.20",
|
|
42
42
|
"@types/react": "^19.2.18",
|
|
43
43
|
"@types/react-dom": "^19.2.5",
|
|
44
44
|
"lightningcss": "^1.32.0",
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
"split2": "4.2.0",
|
|
101
101
|
"tinyrainbow": "3.1.1",
|
|
102
102
|
"xtend": "4.0.2",
|
|
103
|
-
"zod": "4.6.
|
|
103
|
+
"zod": "4.6.5"
|
|
104
104
|
},
|
|
105
105
|
"scripts": {
|
|
106
106
|
"build": "pnpm exec tsdown"
|
|
@@ -120,13 +120,15 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
|
|
|
120
120
|
);
|
|
121
121
|
const textBlock =
|
|
122
122
|
textBlockIndex === -1 ? undefined : (data.content[textBlockIndex] as { text?: string });
|
|
123
|
+
// 编辑(撤回)只要求存在可编辑文本块:轮外消息(location 无 turn/step
|
|
124
|
+
// 归属)同样可撤回——服务端按消息自身位置截断,不依赖轮次边界。
|
|
123
125
|
const onEdit =
|
|
124
|
-
textBlock === undefined
|
|
126
|
+
textBlock === undefined
|
|
125
127
|
? undefined
|
|
126
128
|
: () => {
|
|
127
129
|
setConfirmingRecall({
|
|
128
130
|
key: `${node.anchorSeq}:${String(textBlockIndex)}`,
|
|
129
|
-
turn,
|
|
131
|
+
...(turn === undefined ? {} : { turn }),
|
|
130
132
|
eventSeq: node.anchorSeq,
|
|
131
133
|
blockIndex: textBlockIndex,
|
|
132
134
|
kind: "user",
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Service, type Context } from "@deepseek-ai/cordis";
|
|
|
2
2
|
import type { AssistantMessage, UserMessage } from "@deepseek-ai/dsh-llm";
|
|
3
3
|
import { SessionSeq } from "@deepseek-ai/dsh-session";
|
|
4
4
|
import type {
|
|
5
|
+
AgentCancelCause,
|
|
5
6
|
Session,
|
|
6
7
|
SessionEvent,
|
|
7
8
|
SessionId,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
editableMessages,
|
|
36
37
|
editPlan,
|
|
37
38
|
precedingContentIndex,
|
|
39
|
+
recallBoundary,
|
|
38
40
|
retryPlan,
|
|
39
41
|
rerollPlan,
|
|
40
42
|
retryableTurns,
|
|
@@ -69,6 +71,8 @@ export interface EditorAgent {
|
|
|
69
71
|
followup(message: UserMessage): void;
|
|
70
72
|
/** 等待 agent 到达 quiescence(当前 turn/任务结束后 resolve)。 */
|
|
71
73
|
whenIdle(): Promise<void>;
|
|
74
|
+
// 停止运行中的 loop(上游 Agent.cancel 的 duck-typed 面);缺省表示实现无取消能力。
|
|
75
|
+
cancel?(cause: AgentCancelCause, options?: { keepInbox?: boolean }): void;
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
export interface EditorAgentHandle {
|
|
@@ -232,7 +236,8 @@ export class SessionEditor extends Service {
|
|
|
232
236
|
);
|
|
233
237
|
}
|
|
234
238
|
|
|
235
|
-
rewind(id: SessionId, toBoundary: number, signal?: AbortSignal) {
|
|
239
|
+
async rewind(id: SessionId, toBoundary: number, signal?: AbortSignal) {
|
|
240
|
+
await this.stopLoop(id, signal);
|
|
236
241
|
return this.ctx.sessionBranch.rewind(id, toBoundary, signal);
|
|
237
242
|
}
|
|
238
243
|
|
|
@@ -310,7 +315,7 @@ export class SessionEditor extends Service {
|
|
|
310
315
|
}
|
|
311
316
|
|
|
312
317
|
// 就地编辑:不创建新会话、不改变 id。需要重放排队输入时,先在 rewind
|
|
313
|
-
// 前确保 agent 就绪(
|
|
318
|
+
// 前确保 agent 就绪(cold 先 resume;live 复用驻留 agent)——rewind 截断后
|
|
314
319
|
// agent 无法再以完整会话 resume,且重放失败不应让截断静默丢弃内容。
|
|
315
320
|
const replay = await this.prepareReplay(
|
|
316
321
|
operation.sessionId,
|
|
@@ -319,6 +324,8 @@ export class SessionEditor extends Service {
|
|
|
319
324
|
headerConfig,
|
|
320
325
|
);
|
|
321
326
|
|
|
327
|
+
// rewind 前必须停止运行中的 loop:截断会重写 agent 的 session 内存 log。
|
|
328
|
+
await this.stopLoop(operation.sessionId, signal);
|
|
322
329
|
const live = this.ctx.sessions.get(operation.sessionId);
|
|
323
330
|
await this.ctx.sessionBranch.rewind(operation.sessionId, boundary, signal);
|
|
324
331
|
if (seedSuffix.length > 0) {
|
|
@@ -396,9 +403,9 @@ export class SessionEditor extends Service {
|
|
|
396
403
|
* 撤回(recall):只 rewind 截断,不重写、不重放——被撤回的 user 消息文本
|
|
397
404
|
* 由客户端回填到输入框,交给用户修改后重新发送。
|
|
398
405
|
*
|
|
399
|
-
*
|
|
400
|
-
* (无前轮则 -1),轮内 followup
|
|
401
|
-
*
|
|
406
|
+
* 边界见 {@link recallBoundary}:轮首消息整轮截断到前一轮 `turn/end`
|
|
407
|
+
* (无前轮则 -1),轮内 followup 与轮外 user 消息只截断到该消息本身
|
|
408
|
+
* (exclusive drop),避免留下悬空 `turn/start` 让下一次发送开到错误轮号。
|
|
402
409
|
*/
|
|
403
410
|
private async recallOperation(
|
|
404
411
|
operation: RecallOperation,
|
|
@@ -406,25 +413,9 @@ export class SessionEditor extends Service {
|
|
|
406
413
|
): Promise<SessionEditorResult> {
|
|
407
414
|
signal?.throwIfAborted();
|
|
408
415
|
const events = await this.readEvents(operation.sessionId, signal);
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
operation.eventSeq > turn.startSeq &&
|
|
413
|
-
(turn.endSeq === undefined || operation.eventSeq < turn.endSeq),
|
|
414
|
-
);
|
|
415
|
-
const turn = turns[turnIndex];
|
|
416
|
-
if (turn === undefined) {
|
|
417
|
-
throw new SessionBranchError("所选消息不属于已落定回合。", "INVALID_BOUNDARY");
|
|
418
|
-
}
|
|
419
|
-
const userIndex = turn.users.findIndex((user) => user.seq === operation.eventSeq);
|
|
420
|
-
if (userIndex === -1) {
|
|
421
|
-
throw new SessionBranchError("所选消息不存在或不可撤回。", "INVALID_BOUNDARY");
|
|
422
|
-
}
|
|
423
|
-
const preceding = precedingContentIndex(turns, turnIndex);
|
|
424
|
-
const boundary =
|
|
425
|
-
userIndex === 0 ? (preceding < 0 ? -1 : turns[preceding]!.endSeq!) : operation.eventSeq;
|
|
426
|
-
// live agent 运行中时先等其停下:rewind 会截断其 session 内存 log。
|
|
427
|
-
await this.prepareIdle(operation.sessionId, signal);
|
|
416
|
+
const boundary = recallBoundary(events, closedTurns(events), operation.eventSeq);
|
|
417
|
+
// rewind 前必须停止运行中的 loop:截断会重写 agent 的 session 内存 log。
|
|
418
|
+
await this.stopLoop(operation.sessionId, signal);
|
|
428
419
|
await this.ctx.sessionBranch.rewind(operation.sessionId, boundary, signal);
|
|
429
420
|
return {
|
|
430
421
|
sessionId: operation.sessionId,
|
|
@@ -433,12 +424,15 @@ export class SessionEditor extends Service {
|
|
|
433
424
|
};
|
|
434
425
|
}
|
|
435
426
|
|
|
436
|
-
|
|
437
|
-
|
|
427
|
+
// rewind 前停止运行中的 loop:截断会重写 agent 的 session 内存 log;无
|
|
428
|
+
// live agent 时跳过,实现无取消能力时退化为等待其自然停下(keepInbox
|
|
429
|
+
// 保留排队输入,截断后由 rdb 的 live 钩子 durable 取消)。
|
|
430
|
+
private async stopLoop(sessionId: SessionId, signal?: AbortSignal): Promise<void> {
|
|
438
431
|
signal?.throwIfAborted();
|
|
439
432
|
const agents = this.ctx.get("agents") as EditorAgentRegistry | undefined;
|
|
440
433
|
const existing = agents?.get(sessionId);
|
|
441
434
|
if (existing === undefined) return;
|
|
435
|
+
existing.cancel?.({ kind: "user" }, { keepInbox: true });
|
|
442
436
|
await existing.whenIdle();
|
|
443
437
|
signal?.throwIfAborted();
|
|
444
438
|
}
|
|
@@ -460,12 +454,10 @@ export class SessionEditor extends Service {
|
|
|
460
454
|
return (await branch.readRawEvents(sessionId, signal)).events;
|
|
461
455
|
}
|
|
462
456
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
* 无需重放时返回空——调用方退化为就地截断版本。
|
|
468
|
-
*/
|
|
457
|
+
// rewind 前确保 agent 可驱动重放:cold 会话先 resume 出驻留 agent(此时会话
|
|
458
|
+
// 完整,resume 的 prepare 不与截断冲突);live 会话复用驻留 agent,停止其
|
|
459
|
+
// 运行中的 loop 由 rewind 前的 stopLoop 统一负责。agents 服务缺失或无需
|
|
460
|
+
// 重放时返回空——调用方退化为就地截断版本。
|
|
469
461
|
private async prepareReplay(
|
|
470
462
|
sessionId: SessionId,
|
|
471
463
|
queuedUsers: readonly UserMessage[],
|
|
@@ -478,10 +470,8 @@ export class SessionEditor extends Service {
|
|
|
478
470
|
if (agents === undefined) return { agent: undefined };
|
|
479
471
|
const existing = agents.get(sessionId);
|
|
480
472
|
if (existing !== undefined) {
|
|
481
|
-
await existing.whenIdle();
|
|
482
|
-
signal?.throwIfAborted();
|
|
483
473
|
// 排队输入由 rewind 在截断后强制 durable 取消(session-rdb 的
|
|
484
|
-
// LiveSessionHooks.inbox.clear
|
|
474
|
+
// LiveSessionHooks.inbox.clear),这里只需复用驻留 agent。
|
|
485
475
|
return { agent: existing };
|
|
486
476
|
}
|
|
487
477
|
// cold:resume 已持久化会话(create 会因「已存在持久化日志」失败)。
|
package/src/plan.ts
CHANGED
|
@@ -184,6 +184,31 @@ export function downstreamUsers(turns: readonly ClosedTurn[], start: number): Us
|
|
|
184
184
|
.flatMap((turn): UserMessage[] => turn.users.map((user) => cloneUser(user.data)));
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
// 撤回的 rewind 边界:轮内消息沿用 edit 的轮首规则(轮首整轮截断到前一轮
|
|
188
|
+
// turn/end,无前轮则 -1;轮内 followup 截断到该消息本身),轮外 user 消息按
|
|
189
|
+
// 消息自身位置 exclusive drop(截断该消息及其后全部)。
|
|
190
|
+
export function recallBoundary(
|
|
191
|
+
events: readonly SessionEvent[],
|
|
192
|
+
turns: readonly ClosedTurn[],
|
|
193
|
+
eventSeq: number,
|
|
194
|
+
): number {
|
|
195
|
+
const turnIndex = turns.findIndex(
|
|
196
|
+
(turn) => eventSeq > turn.startSeq && (turn.endSeq === undefined || eventSeq < turn.endSeq),
|
|
197
|
+
);
|
|
198
|
+
const turn = turns[turnIndex];
|
|
199
|
+
if (turn === undefined) {
|
|
200
|
+
const target = events.find((event) => event.seq === eventSeq);
|
|
201
|
+
if (target?.type !== "user/message" || target.data.source.kind !== "user")
|
|
202
|
+
throw new SessionBranchError("所选消息不属于已落定回合。", "INVALID_BOUNDARY");
|
|
203
|
+
return eventSeq;
|
|
204
|
+
}
|
|
205
|
+
const userIndex = turn.users.findIndex((user) => user.seq === eventSeq);
|
|
206
|
+
if (userIndex === -1)
|
|
207
|
+
throw new SessionBranchError("所选消息不存在或不可撤回。", "INVALID_BOUNDARY");
|
|
208
|
+
const preceding = precedingContentIndex(turns, turnIndex);
|
|
209
|
+
return userIndex === 0 ? (preceding < 0 ? -1 : turns[preceding]!.endSeq!) : eventSeq;
|
|
210
|
+
}
|
|
211
|
+
|
|
187
212
|
function assistantReplacement(
|
|
188
213
|
event: SessionEvent<"assistant/message">,
|
|
189
214
|
blockIndex: number,
|
package/src/shared.ts
CHANGED
|
@@ -63,7 +63,8 @@ export interface SessionEditorOperationResult {
|
|
|
63
63
|
|
|
64
64
|
export interface EditableMessageBlock {
|
|
65
65
|
key: string;
|
|
66
|
-
turn
|
|
66
|
+
// 消息所属轮号;轮外消息(无 turn 归属)缺省。
|
|
67
|
+
turn?: number;
|
|
67
68
|
eventSeq: number;
|
|
68
69
|
blockIndex: number;
|
|
69
70
|
kind: "user" | "assistant.reasoning" | "assistant.response";
|
package/src/testing.ts
CHANGED
|
@@ -13,10 +13,12 @@ import { type BranchTimeline } from "@morlay/session-branch";
|
|
|
13
13
|
import SessionPersistenceSqlite from "@morlay/session-rdb";
|
|
14
14
|
import { parseJsonlArtifact } from "@morlay/session-rdb/artifact";
|
|
15
15
|
import { EmptySettings, meta, oneTurnLog } from "@morlay/session-rdb/testing";
|
|
16
|
+
import { SESSION_EDITOR_PATH } from "./shared.ts";
|
|
16
17
|
import { SessionEditor } from "@morlay/ui-conversation-message-actions";
|
|
17
18
|
|
|
18
19
|
export {
|
|
19
20
|
BranchTimeline,
|
|
21
|
+
SESSION_EDITOR_PATH,
|
|
20
22
|
Session,
|
|
21
23
|
SessionIdBrand,
|
|
22
24
|
SessionSeq,
|
|
@@ -35,12 +37,15 @@ export interface Harness {
|
|
|
35
37
|
dispose: () => Promise<void>;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
export async function harness(): Promise<Harness> {
|
|
40
|
+
export async function harness(provide?: (ctx: Context) => void): Promise<Harness> {
|
|
39
41
|
const ctx = new Context();
|
|
40
42
|
await ctx.plugin(EmptySettings);
|
|
41
43
|
await ctx.plugin(SessionStore);
|
|
42
44
|
new SessionProjectionRegistry(ctx);
|
|
43
45
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { type: "sqlite", path: ":memory:" });
|
|
46
|
+
// 额外服务(如 HTTP 面测试的 webServer 替身)必须在 SessionEditor 构造前
|
|
47
|
+
// 就绪:构造函数内的 effect 只在服务可解析时注册路由。
|
|
48
|
+
provide?.(ctx);
|
|
44
49
|
await ctx.plugin(SessionEditor);
|
|
45
50
|
return { ctx, editor: ctx.sessionEditor, dispose: () => fiber.dispose() };
|
|
46
51
|
}
|
package/src/types.ts
CHANGED
|
@@ -65,7 +65,8 @@ export type SessionEditorTimeline = BranchTimeline;
|
|
|
65
65
|
|
|
66
66
|
export interface EditableMessageBlock {
|
|
67
67
|
key: string;
|
|
68
|
-
turn
|
|
68
|
+
// 消息所属轮号;轮外消息(无 turn 归属)缺省。
|
|
69
|
+
turn?: number;
|
|
69
70
|
eventSeq: number;
|
|
70
71
|
blockIndex: number;
|
|
71
72
|
kind: "user" | "assistant.reasoning" | "assistant.response";
|