@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morlay/ui-conversation-message-actions",
3
- "version": "0.0.14-alpha.2",
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-alpha.2"
38
+ "@morlay/session-branch": "^0.0.4"
39
39
  },
40
40
  "devDependencies": {
41
- "@morlay/session-rdb": "^0.0.16-alpha.2",
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-alpha.1",
51
- "@deepseek-ai/dsh-attachment": "^0.1.5-alpha.1",
52
- "@deepseek-ai/dsh-client-connection": "^0.1.5-alpha.1",
53
- "@deepseek-ai/dsh-client-store": "^0.1.5-alpha.1",
54
- "@deepseek-ai/dsh-client-ui-attachment": "^0.1.5-alpha.1",
55
- "@deepseek-ai/dsh-client-ui-chat": "^0.1.5-alpha.1",
56
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-alpha.1",
57
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-alpha.1",
58
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-alpha.1",
59
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-alpha.1",
60
- "@deepseek-ai/dsh-invariants": "^0.1.5-alpha.1",
61
- "@deepseek-ai/dsh-llm": "^0.1.5-alpha.1",
62
- "@deepseek-ai/dsh-session": "^0.1.5-alpha.1",
63
- "@deepseek-ai/dsh-session-persistence": "^0.1.5-alpha.1",
64
- "@deepseek-ai/dsh-session-projection": "^0.1.5-alpha.1",
65
- "@deepseek-ai/dsh-token-meter": "^0.1.5-alpha.1"
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": {
@@ -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
- `${SESSION_EDITOR_PATH}?sessionId=${encodeURIComponent(this.sessionId)}`,
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(SESSION_EDITOR_PATH, {
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) appendManualTurn(seedSuffix, plan.manualTurn);
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
- // 清空 inbox 残留(rewind 将删除这些输入所属轮次的事件;残留消息若
414
- // 不清空,agent 后续 splice 落库后无法从日志重放——inbox 增量投影
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
- const webServer = ctx.get("webServer") as HttpServerLike | undefined;
665
- if (webServer === undefined) return;
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 编辑(闭合轮):整轮截断重放(rewind 到前一轮 turn/end);
233
- // 其余情况(未闭合轮、轮内 followup):rewind 到该消息本身
248
+ // 轮首 user 编辑:整轮截断重放(rewind 到前一轮 turn/end),重放复用
249
+ // 目标轮号;未闭合轮同样整轮截断——保留悬空的 turn/start 会让重放
250
+ // 开到下一轮,轮号对不上。轮内 followup 只 rewind 到该消息本身
234
251
  // (exclusive drop),保留轮内已落定的前置输入与回复。
235
- ...(turn.closed && userIndex === 0 ? {} : { rewindBoundary: event.seq }),
252
+ ...(userIndex === 0 ? {} : { rewindBoundary: event.seq }),
236
253
  version: pairVersionEffect(operation.sessionId, {
237
254
  operation: "edit",
238
255
  cascade: operation.cascade,