@morlay/ui-conversation-message-actions 0.0.15 → 0.0.17
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 +21 -7
- package/dist/client.js +87 -124
- package/dist/index.d.mts +16 -8
- package/dist/index.mjs +1 -1
- package/dist/{plan-BdU_mnTv.d.mts → plan-BSa7k9HO.d.mts} +9 -3
- package/dist/plan.d.mts +2 -2
- package/dist/plan.mjs +14 -1
- package/dist/{src-ks167MhO.mjs → src-B4HwXsob.mjs} +51 -15
- package/dist/testing.d.mts +41 -54
- package/dist/testing.mjs +7432 -400
- package/package.json +20 -20
- package/src/client/chat-node/MessageItem.tsx +31 -11
- package/src/client/controller.ts +30 -2
- package/src/index.ts +73 -12
- package/src/plan.ts +25 -0
- package/src/shared.ts +9 -1
- package/src/testing.ts +6 -1
- package/src/types.ts +10 -1
- package/src/client/chat-node/MessageEditDialog.module.css +0 -24
- package/src/client/chat-node/MessageEditDialog.tsx +0 -71
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.17",
|
|
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.7"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@morlay/session-rdb": "^0.0.
|
|
41
|
+
"@morlay/session-rdb": "^0.0.19",
|
|
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.5-rc.
|
|
51
|
-
"@deepseek-ai/dsh-attachment": "^0.1.5-rc.
|
|
52
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.5-rc.
|
|
53
|
-
"@deepseek-ai/dsh-client-store": "^0.1.5-rc.
|
|
54
|
-
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.5-rc.
|
|
55
|
-
"@deepseek-ai/dsh-client-ui-chat": "^0.1.5-rc.
|
|
56
|
-
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.
|
|
57
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.
|
|
58
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.
|
|
59
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.
|
|
60
|
-
"@deepseek-ai/dsh-invariants": "^0.1.5-rc.
|
|
61
|
-
"@deepseek-ai/dsh-llm": "^0.1.5-rc.
|
|
62
|
-
"@deepseek-ai/dsh-session": "^0.1.5-rc.
|
|
63
|
-
"@deepseek-ai/dsh-session-persistence": "^0.1.5-rc.
|
|
64
|
-
"@deepseek-ai/dsh-session-projection": "^0.1.5-rc.
|
|
65
|
-
"@deepseek-ai/dsh-token-meter": "^0.1.5-rc.
|
|
50
|
+
"@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.2",
|
|
51
|
+
"@deepseek-ai/dsh-attachment": "^0.1.5-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.5-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-client-store": "^0.1.5-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.5-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-chat": "^0.1.5-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.2",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
|
|
60
|
+
"@deepseek-ai/dsh-invariants": "^0.1.5-rc.2",
|
|
61
|
+
"@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
|
|
62
|
+
"@deepseek-ai/dsh-session": "^0.1.5-rc.2",
|
|
63
|
+
"@deepseek-ai/dsh-session-persistence": "^0.1.5-rc.2",
|
|
64
|
+
"@deepseek-ai/dsh-session-projection": "^0.1.5-rc.2",
|
|
65
|
+
"@deepseek-ai/dsh-token-meter": "^0.1.5-rc.2"
|
|
66
66
|
},
|
|
67
67
|
"dsh": {
|
|
68
68
|
"bundle": {
|
|
@@ -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.4"
|
|
104
104
|
},
|
|
105
105
|
"scripts": {
|
|
106
106
|
"build": "pnpm exec tsdown"
|
|
@@ -12,7 +12,6 @@ import { JsonBlock } from "@deepseek-ai/dsh-client-ui-primitives";
|
|
|
12
12
|
import type { ChatNodeViewProps, ChatViewSlotProps } from "@deepseek-ai/dsh-client-ui-chat/client";
|
|
13
13
|
import type { RenderMessageImages } from "@deepseek-ai/dsh-client-ui-conversation/client";
|
|
14
14
|
import { MessageIconActions } from "./MessageIconActions.tsx";
|
|
15
|
-
import { MessageEditDialog } from "./MessageEditDialog.tsx";
|
|
16
15
|
import css from "./MessageItem.module.css";
|
|
17
16
|
import type { EditableMessageBlock } from "../../shared.ts";
|
|
18
17
|
import type { SessionEditorFace } from "../controller.ts";
|
|
@@ -104,11 +103,11 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
|
|
|
104
103
|
node,
|
|
105
104
|
renderMessageImages,
|
|
106
105
|
t,
|
|
107
|
-
|
|
106
|
+
recall,
|
|
108
107
|
retry,
|
|
109
108
|
}: ChatNodeViewProps<"user" | "steering"> & InjectFace<SessionEditorFace>) {
|
|
110
109
|
const data = node.data;
|
|
111
|
-
const [
|
|
110
|
+
const [confirmingRecall, setConfirmingRecall] = useState<EditableMessageBlock | null>(null);
|
|
112
111
|
const [confirmingRetry, setConfirmingRetry] = useState(false);
|
|
113
112
|
const turnLocation =
|
|
114
113
|
node.location.kind === "turn" || node.location.kind === "step" ? node.location.turn : undefined;
|
|
@@ -121,13 +120,15 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
|
|
|
121
120
|
);
|
|
122
121
|
const textBlock =
|
|
123
122
|
textBlockIndex === -1 ? undefined : (data.content[textBlockIndex] as { text?: string });
|
|
123
|
+
// 编辑(撤回)只要求存在可编辑文本块:轮外消息(location 无 turn/step
|
|
124
|
+
// 归属)同样可撤回——服务端按消息自身位置截断,不依赖轮次边界。
|
|
124
125
|
const onEdit =
|
|
125
|
-
textBlock === undefined
|
|
126
|
+
textBlock === undefined
|
|
126
127
|
? undefined
|
|
127
128
|
: () => {
|
|
128
|
-
|
|
129
|
+
setConfirmingRecall({
|
|
129
130
|
key: `${node.anchorSeq}:${String(textBlockIndex)}`,
|
|
130
|
-
turn,
|
|
131
|
+
...(turn === undefined ? {} : { turn }),
|
|
131
132
|
eventSeq: node.anchorSeq,
|
|
132
133
|
blockIndex: textBlockIndex,
|
|
133
134
|
kind: "user",
|
|
@@ -144,11 +145,30 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
|
|
|
144
145
|
: undefined;
|
|
145
146
|
return (
|
|
146
147
|
<>
|
|
147
|
-
{
|
|
148
|
-
<
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
{confirmingRecall !== null && (
|
|
149
|
+
<Modal
|
|
150
|
+
open
|
|
151
|
+
onClose={() => setConfirmingRecall(null)}
|
|
152
|
+
title="编辑消息"
|
|
153
|
+
closeLabel="关闭"
|
|
154
|
+
description="将撤回该消息及其之后的对话内容到输入框,请修改后重新发送。"
|
|
155
|
+
footer={
|
|
156
|
+
<div className={css.confirmActions}>
|
|
157
|
+
<Button variant="outline" onClick={() => setConfirmingRecall(null)}>
|
|
158
|
+
取消
|
|
159
|
+
</Button>
|
|
160
|
+
<Button
|
|
161
|
+
variant="primary"
|
|
162
|
+
onClick={() => {
|
|
163
|
+
const target = confirmingRecall;
|
|
164
|
+
setConfirmingRecall(null);
|
|
165
|
+
void recall(target);
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
撤回并编辑
|
|
169
|
+
</Button>
|
|
170
|
+
</div>
|
|
171
|
+
}
|
|
152
172
|
/>
|
|
153
173
|
)}
|
|
154
174
|
{confirmingRetry && turn !== undefined && (
|
package/src/client/controller.ts
CHANGED
|
@@ -26,7 +26,7 @@ function editorApiPath(): string {
|
|
|
26
26
|
export interface SessionEditorState {
|
|
27
27
|
status: "idle" | "loading" | "ready" | "error";
|
|
28
28
|
error: string | null;
|
|
29
|
-
pending: VersionOperation | "import" | null;
|
|
29
|
+
pending: VersionOperation | "recall" | "import" | null;
|
|
30
30
|
timeline: SessionEditorTimeline | null;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -42,6 +42,7 @@ export interface SessionEditorFace {
|
|
|
42
42
|
retry(turn: number, cascade: "truncate" | "preserve"): Promise<boolean>;
|
|
43
43
|
reroll(): Promise<boolean>;
|
|
44
44
|
rewind(toBoundary: number): Promise<boolean>;
|
|
45
|
+
recall(message: EditableMessageBlock): Promise<boolean>;
|
|
45
46
|
|
|
46
47
|
importSession(file: File): Promise<boolean>;
|
|
47
48
|
openVersion(sessionId: string): Promise<void>;
|
|
@@ -105,6 +106,11 @@ export class SessionEditorController {
|
|
|
105
106
|
reroll: () => this.mutate({ action: "reroll", sessionId: this.sessionId }),
|
|
106
107
|
rewind: (toBoundary) =>
|
|
107
108
|
this.mutate({ action: "rewind", sessionId: this.sessionId, toBoundary }),
|
|
109
|
+
recall: (message) =>
|
|
110
|
+
this.mutate(
|
|
111
|
+
{ action: "recall", sessionId: this.sessionId, eventSeq: message.eventSeq },
|
|
112
|
+
() => this.setComposerDraft(message.text),
|
|
113
|
+
),
|
|
108
114
|
importSession: (file) => this.importSession(file),
|
|
109
115
|
openVersion: (sessionId) => this.openWhenListed(sessionId as SessionId),
|
|
110
116
|
};
|
|
@@ -196,7 +202,10 @@ export class SessionEditorController {
|
|
|
196
202
|
}
|
|
197
203
|
}
|
|
198
204
|
|
|
199
|
-
private async mutate(
|
|
205
|
+
private async mutate(
|
|
206
|
+
operation: SessionEditorOperation,
|
|
207
|
+
onApplied?: () => void,
|
|
208
|
+
): Promise<boolean> {
|
|
200
209
|
// 只拦截并发操作;不要求 status === "ready"——编辑/重试的数据来自客户端
|
|
201
210
|
// conversation 节点,与 timeline 加载无关,status 为 idle 时也可发起请求。
|
|
202
211
|
const current = this.store.getSnapshot();
|
|
@@ -222,6 +231,9 @@ export class SessionEditorController {
|
|
|
222
231
|
this.store.update((state) => {
|
|
223
232
|
state.pending = null;
|
|
224
233
|
});
|
|
234
|
+
// recall 的草稿回填必须在刷新(resync / 整页重载)之前:草稿经会话级
|
|
235
|
+
// 持久化 mirror 落 store,整页重载后仍能恢复。
|
|
236
|
+
onApplied?.();
|
|
225
237
|
const result = value as SessionEditorOperationResult;
|
|
226
238
|
// 仅 fork 产生新 id 时需要等列表发布并打开新版本;就地编辑不改 id。
|
|
227
239
|
if (String(result.sessionId) !== String(this.sessionId)) {
|
|
@@ -266,6 +278,22 @@ export class SessionEditorController {
|
|
|
266
278
|
}
|
|
267
279
|
}
|
|
268
280
|
|
|
281
|
+
/**
|
|
282
|
+
* 把撤回的消息文本回填到该会话的 composer 草稿(`conversation.input` 的
|
|
283
|
+
* `setDraft`,即主输入框的整段替换语义)。会话 scope / binding / conversation
|
|
284
|
+
* 服务缺失时静默跳过——撤回本身已成功,草稿回填失败不应回滚会话状态。
|
|
285
|
+
*/
|
|
286
|
+
private setComposerDraft(text: string): void {
|
|
287
|
+
// binding 缺失时 input.for 会抛(会话 scope 已拆除),先短路。
|
|
288
|
+
if (this.sessions.binding(this.sessionId) === undefined) return;
|
|
289
|
+
const scoped = this.sessions.scope(this.sessionId);
|
|
290
|
+
if (scoped === undefined) return;
|
|
291
|
+
const conversation = scoped.get("conversation") as
|
|
292
|
+
| { input?: { for(ctx: ClientContext): { setDraft(text: string): void } } }
|
|
293
|
+
| undefined;
|
|
294
|
+
conversation?.input?.for(scoped).setDraft(text);
|
|
295
|
+
}
|
|
296
|
+
|
|
269
297
|
private async importSession(file: File): Promise<boolean> {
|
|
270
298
|
const current = this.store.getSnapshot();
|
|
271
299
|
if (current.pending !== null) return false;
|
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,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
import type {
|
|
18
19
|
EditOperation,
|
|
19
20
|
EditableMessageBlock,
|
|
21
|
+
RecallOperation,
|
|
20
22
|
RerollOperation,
|
|
21
23
|
RetryOperation,
|
|
22
24
|
RetryableTurn,
|
|
@@ -34,6 +36,7 @@ import {
|
|
|
34
36
|
editableMessages,
|
|
35
37
|
editPlan,
|
|
36
38
|
precedingContentIndex,
|
|
39
|
+
recallBoundary,
|
|
37
40
|
retryPlan,
|
|
38
41
|
rerollPlan,
|
|
39
42
|
retryableTurns,
|
|
@@ -43,6 +46,7 @@ export type { CascadePolicy, EditableBlockKind } from "@morlay/session-branch";
|
|
|
43
46
|
export type {
|
|
44
47
|
EditableMessageBlock,
|
|
45
48
|
EditOperation,
|
|
49
|
+
RecallOperation,
|
|
46
50
|
RerollOperation,
|
|
47
51
|
RetryOperation,
|
|
48
52
|
RetryableTurn,
|
|
@@ -67,6 +71,8 @@ export interface EditorAgent {
|
|
|
67
71
|
followup(message: UserMessage): void;
|
|
68
72
|
/** 等待 agent 到达 quiescence(当前 turn/任务结束后 resolve)。 */
|
|
69
73
|
whenIdle(): Promise<void>;
|
|
74
|
+
// 停止运行中的 loop(上游 Agent.cancel 的 duck-typed 面);缺省表示实现无取消能力。
|
|
75
|
+
cancel?(cause: AgentCancelCause, options?: { keepInbox?: boolean }): void;
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
export interface EditorAgentHandle {
|
|
@@ -230,7 +236,8 @@ export class SessionEditor extends Service {
|
|
|
230
236
|
);
|
|
231
237
|
}
|
|
232
238
|
|
|
233
|
-
rewind(id: SessionId, toBoundary: number, signal?: AbortSignal) {
|
|
239
|
+
async rewind(id: SessionId, toBoundary: number, signal?: AbortSignal) {
|
|
240
|
+
await this.stopLoop(id, signal);
|
|
234
241
|
return this.ctx.sessionBranch.rewind(id, toBoundary, signal);
|
|
235
242
|
}
|
|
236
243
|
|
|
@@ -250,6 +257,10 @@ export class SessionEditor extends Service {
|
|
|
250
257
|
return this.branchOperation(operation, signal);
|
|
251
258
|
}
|
|
252
259
|
|
|
260
|
+
recall(operation: RecallOperation, signal?: AbortSignal): Promise<SessionEditorResult> {
|
|
261
|
+
return this.recallOperation(operation, signal);
|
|
262
|
+
}
|
|
263
|
+
|
|
253
264
|
async editableMessages(
|
|
254
265
|
sessionId: SessionId,
|
|
255
266
|
signal?: AbortSignal,
|
|
@@ -304,7 +315,7 @@ export class SessionEditor extends Service {
|
|
|
304
315
|
}
|
|
305
316
|
|
|
306
317
|
// 就地编辑:不创建新会话、不改变 id。需要重放排队输入时,先在 rewind
|
|
307
|
-
// 前确保 agent 就绪(
|
|
318
|
+
// 前确保 agent 就绪(cold 先 resume;live 复用驻留 agent)——rewind 截断后
|
|
308
319
|
// agent 无法再以完整会话 resume,且重放失败不应让截断静默丢弃内容。
|
|
309
320
|
const replay = await this.prepareReplay(
|
|
310
321
|
operation.sessionId,
|
|
@@ -313,6 +324,8 @@ export class SessionEditor extends Service {
|
|
|
313
324
|
headerConfig,
|
|
314
325
|
);
|
|
315
326
|
|
|
327
|
+
// rewind 前必须停止运行中的 loop:截断会重写 agent 的 session 内存 log。
|
|
328
|
+
await this.stopLoop(operation.sessionId, signal);
|
|
316
329
|
const live = this.ctx.sessions.get(operation.sessionId);
|
|
317
330
|
await this.ctx.sessionBranch.rewind(operation.sessionId, boundary, signal);
|
|
318
331
|
if (seedSuffix.length > 0) {
|
|
@@ -386,6 +399,44 @@ export class SessionEditor extends Service {
|
|
|
386
399
|
};
|
|
387
400
|
}
|
|
388
401
|
|
|
402
|
+
/**
|
|
403
|
+
* 撤回(recall):只 rewind 截断,不重写、不重放——被撤回的 user 消息文本
|
|
404
|
+
* 由客户端回填到输入框,交给用户修改后重新发送。
|
|
405
|
+
*
|
|
406
|
+
* 边界见 {@link recallBoundary}:轮首消息整轮截断到前一轮 `turn/end`
|
|
407
|
+
* (无前轮则 -1),轮内 followup 与轮外 user 消息只截断到该消息本身
|
|
408
|
+
* (exclusive drop),避免留下悬空 `turn/start` 让下一次发送开到错误轮号。
|
|
409
|
+
*/
|
|
410
|
+
private async recallOperation(
|
|
411
|
+
operation: RecallOperation,
|
|
412
|
+
signal?: AbortSignal,
|
|
413
|
+
): Promise<SessionEditorResult> {
|
|
414
|
+
signal?.throwIfAborted();
|
|
415
|
+
const events = await this.readEvents(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);
|
|
419
|
+
await this.ctx.sessionBranch.rewind(operation.sessionId, boundary, signal);
|
|
420
|
+
return {
|
|
421
|
+
sessionId: operation.sessionId,
|
|
422
|
+
queuedTurns: 0,
|
|
423
|
+
live: this.ctx.sessions.get(operation.sessionId) !== undefined,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// rewind 前停止运行中的 loop:截断会重写 agent 的 session 内存 log;无
|
|
428
|
+
// live agent 时跳过,实现无取消能力时退化为等待其自然停下(keepInbox
|
|
429
|
+
// 保留排队输入,截断后由 rdb 的 live 钩子 durable 取消)。
|
|
430
|
+
private async stopLoop(sessionId: SessionId, signal?: AbortSignal): Promise<void> {
|
|
431
|
+
signal?.throwIfAborted();
|
|
432
|
+
const agents = this.ctx.get("agents") as EditorAgentRegistry | undefined;
|
|
433
|
+
const existing = agents?.get(sessionId);
|
|
434
|
+
if (existing === undefined) return;
|
|
435
|
+
existing.cancel?.({ kind: "user" }, { keepInbox: true });
|
|
436
|
+
await existing.whenIdle();
|
|
437
|
+
signal?.throwIfAborted();
|
|
438
|
+
}
|
|
439
|
+
|
|
389
440
|
private async readEvents(
|
|
390
441
|
sessionId: SessionId,
|
|
391
442
|
signal?: AbortSignal,
|
|
@@ -403,12 +454,10 @@ export class SessionEditor extends Service {
|
|
|
403
454
|
return (await branch.readRawEvents(sessionId, signal)).events;
|
|
404
455
|
}
|
|
405
456
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
* 无需重放时返回空——调用方退化为就地截断版本。
|
|
411
|
-
*/
|
|
457
|
+
// rewind 前确保 agent 可驱动重放:cold 会话先 resume 出驻留 agent(此时会话
|
|
458
|
+
// 完整,resume 的 prepare 不与截断冲突);live 会话复用驻留 agent,停止其
|
|
459
|
+
// 运行中的 loop 由 rewind 前的 stopLoop 统一负责。agents 服务缺失或无需
|
|
460
|
+
// 重放时返回空——调用方退化为就地截断版本。
|
|
412
461
|
private async prepareReplay(
|
|
413
462
|
sessionId: SessionId,
|
|
414
463
|
queuedUsers: readonly UserMessage[],
|
|
@@ -421,10 +470,8 @@ export class SessionEditor extends Service {
|
|
|
421
470
|
if (agents === undefined) return { agent: undefined };
|
|
422
471
|
const existing = agents.get(sessionId);
|
|
423
472
|
if (existing !== undefined) {
|
|
424
|
-
await existing.whenIdle();
|
|
425
|
-
signal?.throwIfAborted();
|
|
426
473
|
// 排队输入由 rewind 在截断后强制 durable 取消(session-rdb 的
|
|
427
|
-
// LiveSessionHooks.inbox.clear
|
|
474
|
+
// LiveSessionHooks.inbox.clear),这里只需复用驻留 agent。
|
|
428
475
|
return { agent: existing };
|
|
429
476
|
}
|
|
430
477
|
// cold:resume 已持久化会话(create 会因「已存在持久化日志」失败)。
|
|
@@ -546,6 +593,12 @@ function decodeOperation(value: unknown): SessionEditorOperation {
|
|
|
546
593
|
sessionId,
|
|
547
594
|
toBoundary: integerOf(record["toBoundary"], "toBoundary"),
|
|
548
595
|
};
|
|
596
|
+
case "recall":
|
|
597
|
+
return {
|
|
598
|
+
action: "recall",
|
|
599
|
+
sessionId,
|
|
600
|
+
eventSeq: integerOf(record["eventSeq"], "eventSeq"),
|
|
601
|
+
};
|
|
549
602
|
case "fork":
|
|
550
603
|
return {
|
|
551
604
|
action: "fork",
|
|
@@ -556,7 +609,7 @@ function decodeOperation(value: unknown): SessionEditorOperation {
|
|
|
556
609
|
: { childSessionId: sessionIdOf(record["childSessionId"]) }),
|
|
557
610
|
};
|
|
558
611
|
default:
|
|
559
|
-
throw new TypeError("action 必须是 edit、reroll、retry、rewind 或 fork。");
|
|
612
|
+
throw new TypeError("action 必须是 edit、reroll、retry、rewind、recall 或 fork。");
|
|
560
613
|
}
|
|
561
614
|
}
|
|
562
615
|
|
|
@@ -629,6 +682,14 @@ async function runOperation(
|
|
|
629
682
|
case "rewind":
|
|
630
683
|
await editor.rewind(operation.sessionId, operation.toBoundary);
|
|
631
684
|
return { sessionId: operation.sessionId, queuedTurns: 0 };
|
|
685
|
+
case "recall": {
|
|
686
|
+
const result = await editor.recall(operation);
|
|
687
|
+
return {
|
|
688
|
+
sessionId: result.sessionId,
|
|
689
|
+
queuedTurns: result.queuedTurns,
|
|
690
|
+
...(result.live === undefined ? {} : { live: result.live }),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
632
693
|
case "fork":
|
|
633
694
|
return {
|
|
634
695
|
sessionId: await editor.fork(
|
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
|
@@ -33,6 +33,12 @@ export interface RewindOperation {
|
|
|
33
33
|
toBoundary: number;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export interface RecallOperation {
|
|
37
|
+
action: "recall";
|
|
38
|
+
sessionId: SessionId;
|
|
39
|
+
eventSeq: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
export interface ForkOperation {
|
|
37
43
|
action: "fork";
|
|
38
44
|
sessionId: SessionId;
|
|
@@ -45,6 +51,7 @@ export type SessionEditorOperation =
|
|
|
45
51
|
| RerollOperation
|
|
46
52
|
| RetryOperation
|
|
47
53
|
| RewindOperation
|
|
54
|
+
| RecallOperation
|
|
48
55
|
| ForkOperation;
|
|
49
56
|
|
|
50
57
|
export interface SessionEditorOperationResult {
|
|
@@ -56,7 +63,8 @@ export interface SessionEditorOperationResult {
|
|
|
56
63
|
|
|
57
64
|
export interface EditableMessageBlock {
|
|
58
65
|
key: string;
|
|
59
|
-
turn
|
|
66
|
+
// 消息所属轮号;轮外消息(无 turn 归属)缺省。
|
|
67
|
+
turn?: number;
|
|
60
68
|
eventSeq: number;
|
|
61
69
|
blockIndex: number;
|
|
62
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
|
@@ -31,6 +31,13 @@ export interface RewindOperation {
|
|
|
31
31
|
toBoundary: number;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export interface RecallOperation {
|
|
35
|
+
action: "recall";
|
|
36
|
+
sessionId: SessionId;
|
|
37
|
+
|
|
38
|
+
eventSeq: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
34
41
|
export interface ForkOperation {
|
|
35
42
|
action: "fork";
|
|
36
43
|
sessionId: SessionId;
|
|
@@ -43,6 +50,7 @@ export type SessionEditorOperation =
|
|
|
43
50
|
| RerollOperation
|
|
44
51
|
| RetryOperation
|
|
45
52
|
| RewindOperation
|
|
53
|
+
| RecallOperation
|
|
46
54
|
| ForkOperation;
|
|
47
55
|
|
|
48
56
|
export interface SessionEditorResult {
|
|
@@ -57,7 +65,8 @@ export type SessionEditorTimeline = BranchTimeline;
|
|
|
57
65
|
|
|
58
66
|
export interface EditableMessageBlock {
|
|
59
67
|
key: string;
|
|
60
|
-
turn
|
|
68
|
+
// 消息所属轮号;轮外消息(无 turn 归属)缺省。
|
|
69
|
+
turn?: number;
|
|
61
70
|
eventSeq: number;
|
|
62
71
|
blockIndex: number;
|
|
63
72
|
kind: "user" | "assistant.reasoning" | "assistant.response";
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
.input {
|
|
2
|
-
/* composer-card 同款视觉(ui-conversation InputBar.module.css 的 .card):
|
|
3
|
-
圆角胶囊 + 输入 surface token + 阴影,编辑弹窗与主输入框观感一致。 */
|
|
4
|
-
width: 100%;
|
|
5
|
-
min-height: 96px;
|
|
6
|
-
box-sizing: border-box;
|
|
7
|
-
font: inherit;
|
|
8
|
-
font-size: 16px;
|
|
9
|
-
line-height: 24px;
|
|
10
|
-
padding: 10px 16px;
|
|
11
|
-
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
|
12
|
-
border-radius: 22px;
|
|
13
|
-
background: var(--dsw-specific-input-major);
|
|
14
|
-
box-shadow: var(--dsw-shadow-lv2);
|
|
15
|
-
color: var(--dsw-text, inherit);
|
|
16
|
-
resize: none;
|
|
17
|
-
overflow-y: auto;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
.actions {
|
|
21
|
-
display: flex;
|
|
22
|
-
justify-content: flex-end;
|
|
23
|
-
gap: 8px;
|
|
24
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { useRef, useState } from "react";
|
|
2
|
-
import { Button, Modal } from "@deepseek-ai/dsh-client-ui-primitives";
|
|
3
|
-
import type { EditableMessageBlock } from "../../shared.ts";
|
|
4
|
-
import css from "./MessageEditDialog.module.css";
|
|
5
|
-
|
|
6
|
-
const BLOCK_TITLE: Record<EditableMessageBlock["kind"], string> = {
|
|
7
|
-
user: "编辑用户消息",
|
|
8
|
-
"assistant.reasoning": "编辑助手思考",
|
|
9
|
-
"assistant.response": "编辑助手回复",
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export function MessageEditDialog({
|
|
13
|
-
block,
|
|
14
|
-
onSave,
|
|
15
|
-
onClose,
|
|
16
|
-
}: {
|
|
17
|
-
block: EditableMessageBlock;
|
|
18
|
-
onSave: (text: string) => Promise<boolean>;
|
|
19
|
-
onClose: () => void;
|
|
20
|
-
}) {
|
|
21
|
-
const [text, setText] = useState(block.text);
|
|
22
|
-
const [saving, setSaving] = useState(false);
|
|
23
|
-
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
24
|
-
|
|
25
|
-
const autosize = (): void => {
|
|
26
|
-
const el = inputRef.current;
|
|
27
|
-
if (el === null) return;
|
|
28
|
-
el.style.height = "auto";
|
|
29
|
-
el.style.height = `${el.scrollHeight}px`;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const save = (): void => {
|
|
33
|
-
if (saving) return;
|
|
34
|
-
setSaving(true);
|
|
35
|
-
void onSave(text).then((applied) => {
|
|
36
|
-
if (applied) onClose();
|
|
37
|
-
else setSaving(false);
|
|
38
|
-
});
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
return (
|
|
42
|
-
<Modal
|
|
43
|
-
open
|
|
44
|
-
onClose={onClose}
|
|
45
|
-
title={BLOCK_TITLE[block.kind]}
|
|
46
|
-
closeLabel="关闭"
|
|
47
|
-
footer={
|
|
48
|
-
<div className={css.actions}>
|
|
49
|
-
<Button variant="outline" onClick={onClose} disabled={saving}>
|
|
50
|
-
取消
|
|
51
|
-
</Button>
|
|
52
|
-
<Button variant="primary" onClick={save} disabled={saving}>
|
|
53
|
-
{saving ? "保存中…" : "保存"}
|
|
54
|
-
</Button>
|
|
55
|
-
</div>
|
|
56
|
-
}
|
|
57
|
-
>
|
|
58
|
-
<textarea
|
|
59
|
-
ref={inputRef}
|
|
60
|
-
className={css.input}
|
|
61
|
-
value={text}
|
|
62
|
-
onChange={(event) => {
|
|
63
|
-
setText(event.target.value);
|
|
64
|
-
autosize();
|
|
65
|
-
}}
|
|
66
|
-
autoFocus
|
|
67
|
-
rows={4}
|
|
68
|
-
/>
|
|
69
|
-
</Modal>
|
|
70
|
-
);
|
|
71
|
-
}
|