@morlay/ui-conversation-message-actions 0.1.1 → 0.1.2

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.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Rewind / retry / recall / fork orchestration (host) plus the browser half — replaces the conversation.chat.node user / steering rendering and overrides the conversation.composer.dock stats row.",
5
5
  "keywords": [
6
6
  "conversation-chat-node",
@@ -38,12 +38,12 @@
38
38
  "./locale/*.json": "./locale/*.json"
39
39
  },
40
40
  "dependencies": {
41
- "@morlay/session-branch": "0.1.1"
41
+ "@morlay/session-branch": "0.1.2"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@deepseek-ai/dsh-session-stats": "0.2.0-rc.1",
45
- "@morlay/dsh-client-ui-primitives": "0.0.4",
46
- "@morlay/session-rdb": "0.1.1",
45
+ "@morlay/dsh-client-ui-primitives": "0.0.5",
46
+ "@morlay/session-rdb": "0.1.2",
47
47
  "@types/react": "~18.3.1",
48
48
  "@types/react-dom": "~18.3.0",
49
49
  "react": "^18.2.0",
@@ -67,8 +67,8 @@
67
67
  "@deepseek-ai/dsh-session-persistence": "^0.2.0-rc.1",
68
68
  "@deepseek-ai/dsh-session-projection": "^0.2.0-rc.1",
69
69
  "@deepseek-ai/dsh-token-meter": "^0.2.0-rc.1",
70
- "@morlay/dsh-client-ui-conversation": "0.1.1",
71
- "@morlay/dsh-client-ui-primitives": "0.0.4"
70
+ "@morlay/dsh-client-ui-conversation": "0.1.2",
71
+ "@morlay/dsh-client-ui-primitives": "0.0.5"
72
72
  },
73
73
  "dsh": {
74
74
  "client": {
@@ -1,7 +1,5 @@
1
- // MessageItem: the user / admitted-steering chat node renderer.
2
- // 官方 `@deepseek-ai/dsh-client-ui-chat` 内置全部 chat-node 渲染器;本项目仅**替换**
3
- // `user`/`steering` 两个 key(keyed slot reuse 即替换),在消息动作行上提供
4
- // edit / retry(上游无此能力)。其余 key 由官方内置渲染器处理。
1
+ // MessageItem: 替换官方内置的 `user` / `steering` 两个 chat-node 渲染器(keyed slot reuse 即替换),
2
+ // 在消息动作行上提供 edit / retry;其余 key 由官方内置渲染器处理。
5
3
 
6
4
  import {
7
5
  markdownLabels,
@@ -1,11 +1,8 @@
1
- // Session stats under the composer, split into two icon pills: a gauge pill
2
- // (turn/step counts + output speed) opening the time-and-speed dialog, and a
3
- // database pill (total tokens + cache hit) opening the token-usage dialog.
4
- // Settled-node identity prevents stream-delta updates from rerendering the row.
5
- // Carried from upstream ui-chat (composer.dock id 'stats', priority -1 shadows it)
6
- // so the fixed token-format (out-of-range cacheRead no longer hangs) stays wired.
7
- // `data-composer-stats` stays as this row's stable anchor (style/test hooks) — the InputBar it once
8
- // paired with is upstream's again, so nothing tightens the composer clearance off it any more.
1
+ // Session stats under the composer, split into two icon pills: a gauge pill (turn/step counts + output speed)
2
+ // opening the time-and-speed dialog, and a database pill (total tokens + cache hit) opening the token-usage
3
+ // dialog. Settled-node identity prevents stream-delta updates from rerendering the row; this row shadows
4
+ // upstream ui-chat's `composer.dock` id `stats`(priority −1),`data-composer-stats` is its stable anchor
5
+ // (style / test hooks)。
9
6
 
10
7
  import { memo, useMemo, useState } from "react";
11
8
  import { createPortal } from "react-dom";
@@ -30,32 +27,24 @@ import { styles as dialogStyles } from "./stat-dialog.styles.ts";
30
27
  interface WindowStats {
31
28
  turns: number;
32
29
  steps: number;
33
- /** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
30
+ // Summed request wall time (step/start → assistant/message); 0 when no node carries timing.
34
31
  llmMs: number;
35
- /** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
32
+ // Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window.
36
33
  toolMs: number;
37
- /** Summed first-token latency over `ttftSteps`; 0 when no step records it. */
34
+ // Summed first-token latency over `ttftSteps`; 0 when no step records it.
38
35
  ttftMs: number;
39
- /** Steps carrying a recorded TTFT. */
36
+ // Steps carrying a recorded TTFT.
40
37
  ttftSteps: number;
41
- /** Summed decode wall time over steps that also report output tokens. */
38
+ // Summed decode wall time over steps that also report output tokens.
42
39
  decodeMs: number;
43
- /** Summed output tokens over the same decode-timed steps. */
40
+ // Summed output tokens over the same decode-timed steps.
44
41
  decodeTokens: number;
45
42
  }
46
43
 
47
- /**
48
- * Fold assistant and tool-result nodes into window-scoped display totals —
49
- * the FALLBACK for assemblies without the `sessionStats` projection.
50
- *
51
- * Every displayed figure rides that durable whole-log projection (and token
52
- * accounting rides `tokenUsage`) because the window is paged and compaction
53
- * rewrites it; this fold answers "what is on screen" only when no projection
54
- * value is served. Its field names deliberately mirror the projection's so
55
- * the two swap wholesale.
56
- * @param nodes - snapshot nodes.
57
- * @returns fallback counts and summed wall times.
58
- */
44
+ // Fold assistant and tool-result nodes into window-scoped display totals — the fallback for assemblies
45
+ // without the `sessionStats` projection. Every displayed figure otherwise rides that durable whole-log
46
+ // projection (token accounting rides `tokenUsage`), because the window is paged and compaction rewrites it;
47
+ // field names deliberately mirror the projection's so the two swap wholesale.
59
48
  export function deriveStats(nodes: ChatSnapshot["legacy"]["nodes"]): WindowStats {
60
49
  const turns = new Set<number>();
61
50
  let steps = 0;
@@ -89,11 +78,7 @@ export function deriveStats(nodes: ChatSnapshot["legacy"]["nodes"]): WindowStats
89
78
  return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens };
90
79
  }
91
80
 
92
- /**
93
- * Compact duration: 45.2s under a minute, 2m42s from there on.
94
- * @param ms - duration in milliseconds.
95
- * @returns display string.
96
- */
81
+ // Compact duration: 45.2s under a minute, 2m42s from there on.
97
82
  export function formatDuration(ms: number, t: ChatViewSlotProps["t"]): string {
98
83
  const s = ms / 1_000;
99
84
  if (s < 60) return t("duration.compactSeconds", { seconds: Math.round(s * 10) / 10 });
@@ -104,32 +89,24 @@ export function formatDuration(ms: number, t: ChatViewSlotProps["t"]): string {
104
89
  });
105
90
  }
106
91
 
107
- /**
108
- * Display-ready cache-hit share of prompt-side input over the whole durable log.
109
- * @param usage - the session's token-usage projection value.
110
- * @returns integer text when integer rounding stays below 100, otherwise the
111
- * minimum decimal precision that still rounds below 100; a full hit returns
112
- * 100, and no billed input returns null.
113
- */
92
+ // Display-ready cache-hit share of prompt-side input over the whole durable log: integer text while integer
93
+ // rounding stays below 100, otherwise the minimum decimal precision that still rounds below 100; a full hit
94
+ // returns 100, and no billed input returns null.
114
95
  export function cacheHitPercent(usage: TokenUsageProjection): string | null {
115
96
  const denominator = billedInputTokens(usage);
116
97
  return formatCacheHitPercent(usage.cacheReadTokens, denominator);
117
98
  }
118
99
 
119
- /**
120
- * Sum the three disjoint prompt-side billing buckets.
121
- * @param usage - the session's token-usage projection value.
122
- * @returns billed input tokens.
123
- */
100
+ // Sum the three disjoint prompt-side billing buckets.
124
101
  export function billedInputTokens(usage: TokenUsageProjection): number {
125
102
  return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
126
103
  }
127
104
 
128
- /** Props: the conversation-snapshot selector plus the projection read seat. */
105
+ // Props: the conversation-snapshot selector plus the projection read seat.
129
106
  export interface StatsPillsProps {
130
107
  useChat: SnapshotSelectorHook<ChatSnapshot>;
131
108
  useProjection: UseProjection;
132
- /** The owning dock's locale seat. */
109
+ // The owning dock's locale seat.
133
110
  t: ChatViewSlotProps["t"];
134
111
  }
135
112
 
@@ -137,7 +114,7 @@ function exactCount(value: number, t: ChatViewSlotProps["t"]): string {
137
114
  return t("message.turnUsage.count", { count: formatExactTokens(value, t) });
138
115
  }
139
116
 
140
- /** External open state one pill's dialog reads and writes (the row's exclusive slot). */
117
+ // External open state one pill's dialog reads and writes (the row's exclusive slot).
141
118
  type PillDialog = Pick<ReturnType<typeof useStatDialog>, "open" | "setOpen">;
142
119
 
143
120
  function TimePill({
@@ -345,10 +322,8 @@ export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }
345
322
  const usage = useProjection("tokenUsage");
346
323
  // One exclusive slot for both dialogs: opening either pill closes the other.
347
324
  const [openPill, setOpenPill] = useState<"time" | "usage" | null>(null);
348
- // Every figure rides the durable sessionStats projection, so paging and
349
- // compaction cannot change any of them; an assembly without the unit falls
350
- // back to the window-scoped fold wholesale (same field names), paid only
351
- // while no projection value is served.
325
+ // Every figure rides the durable sessionStats projection, so paging and compaction cannot change any of
326
+ // them; an assembly without the unit falls back to the window-scoped fold wholesale (same field names).
352
327
  const projected = useProjection("sessionStats");
353
328
  const stats = useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes]);
354
329
  // Gated on actual token activity: a session whose steps all settled without
@@ -1,6 +1,5 @@
1
- // Token 展示口径,随 composer 统计行从上游 ui-chat 搬来:`formatCacheHitPercent`
2
- // 的越界分支是**主动修的缺陷**(上游只在未命中侧为 0 时返回 "100",
3
- // cacheRead 超出 prompt 时展开循环的缩放间隙恒为负 → 同步死循环冻结主线程)。
1
+ // Token 展示口径:`formatCacheHitPercent` 的越界分支是**主动修的缺陷**(上游只在未命中侧为 0 时返回 "100",
2
+ // cacheRead 超出 prompt 时展开循环的缩放间隙恒为负 → 同步死循环冻结主线程),这里保持修好的口径。
4
3
  import type { ChatViewSlotProps } from "@deepseek-ai/dsh-client-ui-chat/client";
5
4
 
6
5
  export function formatTokens(value: number, t: ChatViewSlotProps["t"]): string {
@@ -2,23 +2,14 @@ import type { Context as ClientContext } from "@deepseek-ai/cordis";
2
2
  import type { ISessions } from "@deepseek-ai/dsh-api-session-controller/client";
3
3
  import type { SessionId } from "@deepseek-ai/dsh-session";
4
4
  import {
5
- SESSION_EDITOR_PATH,
5
+ SESSION_EDITOR_PATHS,
6
6
  type EditableMessageBlock,
7
7
  type SessionEditorOperation,
8
8
  type VersionOperation,
9
9
  } from "../shared.ts";
10
10
 
11
- /** 编辑器的 HTTP 路径:宿主(web 与桌面)在同一张路由表上服务它,页面不再按 ownsHost 加前缀。 */
12
- const EDITOR_API_PATH = SESSION_EDITOR_PATH;
13
-
14
- /**
15
- * 会话编辑的浏览器半门面:只保留消息渲染面真正用到的两个动作。
16
- *
17
- * 曾经这里还有一条「订阅会话列表 / 快照 → 拉全量 timeline → 装进 store」的刷新管路,
18
- * 它没有任何 UI 消费方,却把重放 / 流式期间的每一次快照抖动都换成一次全量 GET
19
- * (编辑 / 重试 / 撤回后尤甚),订阅本身也从不释放。现在动作成功后只刷新会话列表元数据,
20
- * 会话窗口交给上游的事件流收敛(不重建窗口、不整页重载)。
21
- */
11
+ // 会话编辑的浏览器半门面:只保留消息渲染面真正用到的两个动作;动作成功后只刷新会话列表元数据,
12
+ // 会话窗口交给上游的事件流收敛(不重建窗口、不整页重载)。
22
13
  export interface SessionEditorFace {
23
14
  // 属性式函数类型而非方法签名:这两动作要被渲染面解构后直接调用,没有 `this` 可言。
24
15
  retry: (turn: number, cascade: "truncate" | "preserve") => Promise<boolean>;
@@ -64,10 +55,11 @@ export class SessionEditorController {
64
55
  if (this.pending !== null) return false;
65
56
  this.pending = operation.action;
66
57
  try {
67
- const response = await fetch(EDITOR_API_PATH, {
58
+ const { action, ...payload } = operation;
59
+ const response = await fetch(SESSION_EDITOR_PATHS[action], {
68
60
  method: "POST",
69
61
  headers: { accept: "application/json", "content-type": "application/json" },
70
- body: JSON.stringify(operation),
62
+ body: JSON.stringify(payload),
71
63
  });
72
64
  const value = (await response.json()) as unknown;
73
65
  if (!response.ok) {
@@ -79,10 +71,9 @@ export class SessionEditorController {
79
71
 
80
72
  onApplied?.();
81
73
 
82
- // 会话窗口必须重建:rewind 把日志 seq 回退了,窗口里已渲染的旧节点不会自己消失
83
- // (实测:被裁剪的消息残留、新消息也追加不进去)。重建入口只在**运行时**对象上
84
- // (ClientSession 的 `resync()`;类型面 `SessionFace` 并未暴露它),所以逐个探测;
85
- // 都拿不到时记一条 warn 便于诊断,且不回退整页重载(刷新会打断用户)。
74
+ // 会话窗口必须重建:rewind 把日志 seq 回退了,窗口里已渲染的旧节点不会自己消失(被裁剪的消息残留、
75
+ // 新消息也追加不进去)。重建入口只在**运行时**对象上(`resync()`,类型面 `SessionFace` 未暴露),所以
76
+ // 逐个探测;都拿不到时记一条 warn 便于诊断,不回退整页重载。
86
77
  try {
87
78
  await this.rebuildWindow();
88
79
  } catch (error: unknown) {
@@ -96,12 +87,9 @@ export class SessionEditorController {
96
87
  }
97
88
  }
98
89
 
99
- /**
100
- * 让上游重开该会话的历史窗口(rewind 之后客户端窗口的 seq 基线已经失效)。
101
- *
102
- * 探测顺序:`binding(id).session.resync()`(上游 ClientSession 的重建入口,类型面没暴露)
103
- * → `sessions.refresh()`(只刷列表元数据,聊胜于无)→ 都没有就记一条 warn。
104
- */
90
+ // 让上游重开该会话的历史窗口(rewind 之后客户端窗口的 seq 基线已经失效):探测顺序
91
+ // `binding(id).session.resync()`(上游 ClientSession 的重建入口,类型面没暴露)→ `sessions.refresh()`
92
+ // (只刷列表元数据)→ 都没有就记一条 warn。
105
93
  private async rebuildWindow(): Promise<void> {
106
94
  const sessions = this.sessions as unknown as {
107
95
  binding?: (id: SessionId) => { session?: { resync?: () => Promise<void> } } | undefined;
@@ -122,14 +110,9 @@ export class SessionEditorController {
122
110
  );
123
111
  }
124
112
 
125
- /**
126
- * 撤回后把会话视口送回底部:rewind 把窗口换短,而上游 ChatView 只在读者已经贴底时
127
- * 才跟随(`tipMoved && atBottom`),停在中途的视口会悬在被截断的位置上;撤回的下一步
128
- * 是改完再发,所以这里无条件送到底。容器用上游既有契约 `[data-conversation-scroll]`
129
- * (ChatView / ConversationWidthControls / StatsPills 同用)。
130
- *
131
- * 窗口替换的渲染落在 resync 之后:写早了会被下一轮布局覆盖,因此等两帧再写。
132
- */
113
+ // 撤回后把会话视口送回底部:rewind 把窗口换短,而上游 ChatView 只在读者已经贴底时才跟随
114
+ // (`tipMoved && atBottom`),停在中途的视口会悬在被截断的位置上;撤回的下一步是改完再发,所以这里无条件
115
+ // 送到底。容器用上游既有契约 `[data-conversation-scroll]`;窗口替换的渲染落在 resync 之后,因此等两帧再写。
133
116
  private returnViewportToEnd(): void {
134
117
  const scrollport = document.querySelector<HTMLElement>("[data-conversation-scroll]");
135
118
  if (scrollport === null) return;
package/src/index.ts CHANGED
@@ -22,7 +22,8 @@ import type {
22
22
  SessionEditorResult,
23
23
  } from "./types.ts";
24
24
  import {
25
- SESSION_EDITOR_PATH,
25
+ SESSION_EDITOR_PATHS,
26
+ type SessionEditorAction,
26
27
  type SessionEditorOperation,
27
28
  type SessionEditorOperationResult,
28
29
  } from "./shared.ts";
@@ -30,6 +31,7 @@ import {
30
31
  closedTurns,
31
32
  droppedCompactions,
32
33
  editPlan,
34
+ keptConfigEvents,
33
35
  precedingContentIndex,
34
36
  recallBoundary,
35
37
  restoreCompactions,
@@ -259,6 +261,8 @@ export class SessionEditor extends Service {
259
261
  // 补回被截断丢掉、但在重放目标之前的压缩
260
262
  const keepFrom = keepFromOf(events, boundary);
261
263
  const compactions = droppedCompactions(events, keepFrom, plan.targetSeq);
264
+ // 同理保留这之前的配置事件(它们表达的是当前生效的配置)
265
+ const configs = keptConfigEvents(events, keepFrom, plan.targetSeq);
262
266
 
263
267
  const replayTurn = preceding < 0 ? 1 : turns[preceding]!.turn + 1;
264
268
 
@@ -281,6 +285,7 @@ export class SessionEditor extends Service {
281
285
  const seedStart = this.seedStart(live, events, keepFrom);
282
286
  const seedSuffix: SessionEvent[] = [
283
287
  ...restoreCompactions(compactions, keepFrom, seedStart),
288
+ ...configs,
284
289
  ...manualSeed,
285
290
  ];
286
291
  await this.appendSeedSuffix(operation.sessionId, live, seedSuffix, events, keepFrom);
@@ -311,9 +316,10 @@ export class SessionEditor extends Service {
311
316
  signal?.throwIfAborted();
312
317
  const events = await this.readEvents(operation.sessionId, signal);
313
318
  const boundary = recallBoundary(events, closedTurns(events), operation.eventSeq);
314
- // 撤回同样要补回被丢掉、但在撤回目标之前的压缩
319
+ // 撤回同样要补回被丢掉、但在撤回目标之前的压缩与配置事件
315
320
  const keepFrom = keepFromOf(events, boundary);
316
321
  const compactions = droppedCompactions(events, keepFrom, operation.eventSeq);
322
+ const configs = keptConfigEvents(events, keepFrom, operation.eventSeq);
317
323
 
318
324
  await this.stopLoop(operation.sessionId, signal);
319
325
  const live = this.ctx.sessions.get(operation.sessionId);
@@ -321,7 +327,10 @@ export class SessionEditor extends Service {
321
327
  await this.appendSeedSuffix(
322
328
  operation.sessionId,
323
329
  live,
324
- restoreCompactions(compactions, keepFrom, this.seedStart(live, events, keepFrom)),
330
+ [
331
+ ...restoreCompactions(compactions, keepFrom, this.seedStart(live, events, keepFrom)),
332
+ ...configs,
333
+ ],
325
334
  events,
326
335
  keepFrom,
327
336
  );
@@ -491,10 +500,10 @@ function cascadeOf(value: unknown): import("@morlay/session-branch").CascadePoli
491
500
  return value;
492
501
  }
493
502
 
494
- function decodeOperation(value: unknown): SessionEditorOperation {
503
+ function decodeOperation(value: unknown, action: SessionEditorAction): SessionEditorOperation {
495
504
  const record = objectValue(value);
496
505
  const sessionId = sessionIdOf(record["sessionId"]);
497
- switch (record["action"]) {
506
+ switch (action) {
498
507
  case "edit":
499
508
  if (typeof record["text"] !== "string") throw new TypeError("text 必须是字符串。");
500
509
  return {
@@ -621,6 +630,7 @@ async function runOperation(
621
630
 
622
631
  async function handleRoute(
623
632
  editor: SessionEditor,
633
+ action: SessionEditorAction,
624
634
  request: HttpRequestLike,
625
635
  response: HttpResponseLike,
626
636
  ): Promise<void> {
@@ -629,7 +639,7 @@ async function handleRoute(
629
639
  respondJson(
630
640
  response,
631
641
  200,
632
- await runOperation(editor, decodeOperation(await requestJson(request))),
642
+ await runOperation(editor, decodeOperation(await requestJson(request), action)),
633
643
  );
634
644
  return;
635
645
  }
@@ -651,14 +661,18 @@ function registerHttpRoutes(ctx: Context, editor: SessionEditor): void {
651
661
  // 这里只按用到的 register 面做结构转换,不再 declare module 覆盖,否则两处声明冲突(TS2717)。
652
662
  const webServer = scope.get("webServer") as unknown as HttpServerLike | undefined;
653
663
  if (webServer === undefined) return;
654
- scope.effect(
655
- () =>
656
- webServer.register({
657
- kind: "exact",
658
- path: SESSION_EDITOR_PATH,
659
- handler: (request, response) => handleRoute(editor, request, response),
660
- }),
661
- "session-editor: HTTP route",
662
- );
664
+ // 一条动作一条 exact 路由:动作由路径定,body 里不带 `action`(见 shared.ts 的 SESSION_EDITOR_PATHS)。
665
+ for (const [action, path] of Object.entries(SESSION_EDITOR_PATHS)) {
666
+ const verb = action as SessionEditorAction;
667
+ scope.effect(
668
+ () =>
669
+ webServer.register({
670
+ kind: "exact",
671
+ path,
672
+ handler: (request, response) => handleRoute(editor, verb, request, response),
673
+ }),
674
+ `session-editor: HTTP route ${path}`,
675
+ );
676
+ }
663
677
  });
664
678
  }
package/src/plan.ts CHANGED
@@ -171,6 +171,8 @@ export function downstreamUsers(turns: readonly ClosedTurn[], start: number): Us
171
171
  .flatMap((turn): UserMessage[] => turn.users.map((user) => cloneUser(user.data)));
172
172
  }
173
173
 
174
+ // 同轮内第一条 user 退到前一轮的 turn/end(撤回整轮):留着本轮的 turn/start,之后的重放会按
175
+ // `phase.turn + 1` 再开一轮、本轮成空轮。该轮里这条消息之前写下的配置事件由 recallOperation 重写保留。
174
176
  export function recallBoundary(
175
177
  events: readonly SessionEvent[],
176
178
  turns: readonly ClosedTurn[],
@@ -243,6 +245,9 @@ export function editPlan(operation: EditOperation, turns: readonly ClosedTurn[])
243
245
  return {
244
246
  anchorSeq: turn.startSeq,
245
247
 
248
+ // 轮内第一条 user 退到前一轮的 turn/end(整轮重放):留着本轮的 turn/start,上游重放会按
249
+ // `phase.turn + 1` 再开一轮,本轮就成了空轮(agent-loop 的 turn 推进不看未闭合轮)。
250
+ // 该轮里这条消息之前写下的配置事件由 branchOperation 重写保留。
246
251
  ...(userIndex === 0 ? {} : { rewindBoundary: event.seq }),
247
252
  queuedUsers: [edited, ...sameTurnFollowups, ...later],
248
253
  targetSeq: event.seq,
@@ -373,6 +378,29 @@ export function droppedCompactions(
373
378
  );
374
379
  }
375
380
 
381
+ // 会话级配置事件:写入即「最后一条生效」,与某一轮的正文无关(分类同 session-rdb 的事件 schema)。
382
+ const CONFIG_EVENT_TYPES: ReadonlySet<string> = new Set([
383
+ "model/selection",
384
+ "permission/preset",
385
+ "approval/policy",
386
+ "sandbox/mode",
387
+ "plan/mode",
388
+ "agent-preset/selected",
389
+ "session-mode/selected",
390
+ ]);
391
+
392
+ // 截断丢掉、但位于目标消息之前的配置事件:撤回整轮时它们要重写回保留前缀之后——否则当前生效的配置
393
+ // 跟着撤回了。轮内后续 user 消息的边界不退整轮,这个区间自然为空(那些事件本来就在保留前缀里)。
394
+ export function keptConfigEvents(
395
+ events: readonly SessionEvent[],
396
+ keepFrom: number,
397
+ targetSeq: number,
398
+ ): SessionEvent[] {
399
+ return events.filter(
400
+ (event) => event.seq >= keepFrom && event.seq < targetSeq && CONFIG_EVENT_TYPES.has(event.type),
401
+ );
402
+ }
403
+
376
404
  function replacementRange(event: SessionEvent): { start: number; end: number } | undefined {
377
405
  const op = (
378
406
  event as unknown as { surfaceOp?: { op?: unknown; startSeq?: unknown; endSeq?: unknown } }
package/src/shared.ts CHANGED
@@ -1,7 +1,18 @@
1
1
  import type { SessionId } from "@deepseek-ai/dsh-session";
2
2
  import type { CascadePolicy } from "@morlay/session-branch";
3
3
 
4
- export const SESSION_EDITOR_PATH = "/session-editor";
4
+ // 会话编辑面的六条 POST 路径:一条动作一条路径(路径定动作,body 只带该动作的参数)。
5
+ // 与上游官方路由分开的理由见 ADR-管理面HTTP路由前缀与上游分家。
6
+ export const SESSION_EDITOR_PATHS = {
7
+ edit: "/api/morlay/v1/session/edit",
8
+ reroll: "/api/morlay/v1/session/reroll",
9
+ retry: "/api/morlay/v1/session/retry",
10
+ rewind: "/api/morlay/v1/session/rewind",
11
+ recall: "/api/morlay/v1/session/recall",
12
+ fork: "/api/morlay/v1/session/fork",
13
+ } as const;
14
+
15
+ export type SessionEditorAction = keyof typeof SESSION_EDITOR_PATHS;
5
16
 
6
17
  export type { VersionOperation } from "@morlay/session-branch";
7
18
 
package/src/testing.ts CHANGED
@@ -12,11 +12,11 @@ import SessionProjectionRegistry from "@deepseek-ai/dsh-session-projection";
12
12
  import SessionPersistenceSqlite from "@morlay/session-rdb";
13
13
  import { parseJsonlArtifact } from "@morlay/session-rdb/artifact";
14
14
  import { meta, oneTurnLog } from "@morlay/session-rdb/testing";
15
- import { SESSION_EDITOR_PATH } from "./shared.ts";
15
+ import { SESSION_EDITOR_PATHS } from "./shared.ts";
16
16
  import { SessionEditor } from "@morlay/ui-conversation-message-actions";
17
17
 
18
18
  export {
19
- SESSION_EDITOR_PATH,
19
+ SESSION_EDITOR_PATHS,
20
20
  Session,
21
21
  SessionIdBrand,
22
22
  SessionSeq,
@@ -26,6 +26,7 @@ export {
26
26
  oneTurnLog,
27
27
  parseJsonlArtifact,
28
28
  };
29
+ export type { SessionEditorAction } from "./shared.ts";
29
30
  export type { SessionEvent, SessionHeader };
30
31
 
31
32
  export interface Harness {
@@ -1,4 +0,0 @@
1
- //#region src/shared.ts
2
- const SESSION_EDITOR_PATH = "/session-editor";
3
- //#endregion
4
- export { SESSION_EDITOR_PATH as t };