@morlay/ui-conversation-message-actions 0.0.14-alpha.2 → 0.0.14
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 +36 -26
- package/dist/index.d.mts +1 -9
- package/dist/index.mjs +1 -1
- package/dist/{plan-C-_enFsA.d.mts → plan-BdU_mnTv.d.mts} +8 -1
- package/dist/plan.d.mts +2 -2
- package/dist/plan.mjs +17 -2
- package/dist/{src-DqUEYw-N.mjs → src-ks167MhO.mjs} +52 -7
- package/dist/testing.mjs +187 -151
- package/package.json +19 -19
- package/src/client/controller.ts +21 -2
- package/src/index.ts +97 -22
- 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
|
|
3
|
+
"version": "0.0.14",
|
|
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
|
|
38
|
+
"@morlay/session-branch": "^0.0.4"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@morlay/session-rdb": "^0.0.16
|
|
41
|
+
"@morlay/session-rdb": "^0.0.16",
|
|
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-
|
|
51
|
-
"@deepseek-ai/dsh-attachment": "^0.1.5-
|
|
52
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.5-
|
|
53
|
-
"@deepseek-ai/dsh-client-store": "^0.1.5-
|
|
54
|
-
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.5-
|
|
55
|
-
"@deepseek-ai/dsh-client-ui-chat": "^0.1.5-
|
|
56
|
-
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-
|
|
57
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-
|
|
58
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-
|
|
59
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-
|
|
60
|
-
"@deepseek-ai/dsh-invariants": "^0.1.5-
|
|
61
|
-
"@deepseek-ai/dsh-llm": "^0.1.5-
|
|
62
|
-
"@deepseek-ai/dsh-session": "^0.1.5-
|
|
63
|
-
"@deepseek-ai/dsh-session-persistence": "^0.1.5-
|
|
64
|
-
"@deepseek-ai/dsh-session-projection": "^0.1.5-
|
|
65
|
-
"@deepseek-ai/dsh-token-meter": "^0.1.5-
|
|
50
|
+
"@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.1",
|
|
51
|
+
"@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
|
|
52
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.5-rc.1",
|
|
53
|
+
"@deepseek-ai/dsh-client-store": "^0.1.5-rc.1",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.5-rc.1",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-chat": "^0.1.5-rc.1",
|
|
56
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.1",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.1",
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
|
|
60
|
+
"@deepseek-ai/dsh-invariants": "^0.1.5-rc.1",
|
|
61
|
+
"@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
|
|
62
|
+
"@deepseek-ai/dsh-session": "^0.1.5-rc.1",
|
|
63
|
+
"@deepseek-ai/dsh-session-persistence": "^0.1.5-rc.1",
|
|
64
|
+
"@deepseek-ai/dsh-session-projection": "^0.1.5-rc.1",
|
|
65
|
+
"@deepseek-ai/dsh-token-meter": "^0.1.5-rc.1"
|
|
66
66
|
},
|
|
67
67
|
"dsh": {
|
|
68
68
|
"bundle": {
|
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
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
closedTurns,
|
|
34
34
|
editableMessages,
|
|
35
35
|
editPlan,
|
|
36
|
+
precedingContentIndex,
|
|
36
37
|
retryPlan,
|
|
37
38
|
rerollPlan,
|
|
38
39
|
retryableTurns,
|
|
@@ -66,14 +67,6 @@ export interface EditorAgent {
|
|
|
66
67
|
followup(message: UserMessage): void;
|
|
67
68
|
/** 等待 agent 到达 quiescence(当前 turn/任务结束后 resolve)。 */
|
|
68
69
|
whenIdle(): Promise<void>;
|
|
69
|
-
/**
|
|
70
|
-
* 队列中是否有待处理输入(next-turn / next-step)。rewind 截断会删除这些
|
|
71
|
-
* 输入所属轮次的事件,残留的 inbox 状态会与截断后的 log 失配,使后续
|
|
72
|
-
* splice 落库后无法从日志重放——编辑前必须清空。
|
|
73
|
-
*/
|
|
74
|
-
readonly inboxPending: boolean;
|
|
75
|
-
/** 清空待处理输入(落库 canceled splice,须在 rewind 前调用)。 */
|
|
76
|
-
clearInbox(): void;
|
|
77
70
|
}
|
|
78
71
|
|
|
79
72
|
export interface EditorAgentHandle {
|
|
@@ -288,11 +281,27 @@ export class SessionEditor extends Service {
|
|
|
288
281
|
const headerConfig = events.findLast((event) => event.type === "request/header")?.data.header
|
|
289
282
|
.config;
|
|
290
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
|
+
|
|
291
298
|
// 派生 seed 后缀:版本效果 + 可选手工回合。版本事件对核心是 ignorable,
|
|
292
299
|
// 保证非 branch 读者可安全跳过。
|
|
293
300
|
const seedSuffix: SessionEvent[] = [];
|
|
294
301
|
appendLogSeedEvent(seedSuffix, "session-branch/version", plan.version, true);
|
|
295
|
-
if (plan.manualTurn !== undefined)
|
|
302
|
+
if (plan.manualTurn !== undefined) {
|
|
303
|
+
appendManualTurn(seedSuffix, { ...plan.manualTurn, turn: replayTurn });
|
|
304
|
+
}
|
|
296
305
|
|
|
297
306
|
// 就地编辑:不创建新会话、不改变 id。需要重放排队输入时,先在 rewind
|
|
298
307
|
// 前确保 agent 就绪(live agent 等其停;cold 先 resume)——rewind 截断后
|
|
@@ -304,13 +313,6 @@ export class SessionEditor extends Service {
|
|
|
304
313
|
headerConfig,
|
|
305
314
|
);
|
|
306
315
|
|
|
307
|
-
const turnIndex = turns.findIndex((turn) => turn.startSeq === plan.anchorSeq);
|
|
308
|
-
const boundary =
|
|
309
|
-
plan.rewindBoundary !== undefined
|
|
310
|
-
? plan.rewindBoundary
|
|
311
|
-
: turnIndex <= 0
|
|
312
|
-
? -1
|
|
313
|
-
: turns[turnIndex - 1]!.endSeq!;
|
|
314
316
|
const live = this.ctx.sessions.get(operation.sessionId);
|
|
315
317
|
await this.ctx.sessionBranch.rewind(operation.sessionId, boundary, signal);
|
|
316
318
|
if (seedSuffix.length > 0) {
|
|
@@ -333,6 +335,10 @@ export class SessionEditor extends Service {
|
|
|
333
335
|
`session "${operation.sessionId}" has no live write handle for the version effect`,
|
|
334
336
|
);
|
|
335
337
|
}
|
|
338
|
+
// rewind 截断后可能已追加事件(如 inbox 取消 splice),它们仍在
|
|
339
|
+
// write-behind 缓冲里;直接对 handle 写版本效果前必须先把缓冲落盘,
|
|
340
|
+
// 否则 handle cursor 落后于 log 长度 → append seq mismatch。
|
|
341
|
+
await this.ctx.sessions.flush(live);
|
|
336
342
|
await appendSeedSuffixLive(live, seedSuffix, (events) => handle.append(events));
|
|
337
343
|
await this.ctx.sessions.flush(live);
|
|
338
344
|
} else {
|
|
@@ -361,6 +367,13 @@ export class SessionEditor extends Service {
|
|
|
361
367
|
// 缺失)时退化为已 durable 的就地版本。
|
|
362
368
|
let queuedTurns = 0;
|
|
363
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
|
+
}
|
|
364
377
|
for (const message of plan.queuedUsers) replay.agent.followup(message);
|
|
365
378
|
await this.ctx.sessions.flush(replay.agent.session);
|
|
366
379
|
queuedTurns = plan.queuedUsers.length;
|
|
@@ -410,10 +423,8 @@ export class SessionEditor extends Service {
|
|
|
410
423
|
if (existing !== undefined) {
|
|
411
424
|
await existing.whenIdle();
|
|
412
425
|
signal?.throwIfAborted();
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
// 假设 log 只 append)。
|
|
416
|
-
if (existing.inboxPending) existing.clearInbox();
|
|
426
|
+
// 排队输入由 rewind 在截断后强制 durable 取消(session-rdb 的
|
|
427
|
+
// LiveSessionHooks.inbox.clear),这里只需保证 agent 已停下。
|
|
417
428
|
return { agent: existing };
|
|
418
429
|
}
|
|
419
430
|
// cold:resume 已持久化会话(create 会因「已存在持久化日志」失败)。
|
|
@@ -661,9 +672,11 @@ async function handleRoute(
|
|
|
661
672
|
}
|
|
662
673
|
|
|
663
674
|
function registerHttpRoutes(ctx: Context): void {
|
|
664
|
-
|
|
665
|
-
|
|
675
|
+
// web 模式:注册到 webServer(dsh web 的 HTTP 面)。服务在构造期可能
|
|
676
|
+
// 尚未就绪,effect 回调在插件激活后执行,此时 ctx.get 才能取到。
|
|
666
677
|
ctx.effect(() => {
|
|
678
|
+
const webServer = ctx.get("webServer") as HttpServerLike | undefined;
|
|
679
|
+
if (webServer === undefined) return () => {};
|
|
667
680
|
const editor = ctx.sessionEditor;
|
|
668
681
|
return webServer.register({
|
|
669
682
|
kind: "exact",
|
|
@@ -671,4 +684,66 @@ function registerHttpRoutes(ctx: Context): void {
|
|
|
671
684
|
handler: (request, response) => handleRoute(editor, request, response),
|
|
672
685
|
});
|
|
673
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
|
+
});
|
|
674
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,
|