@morlay/ui-conversation-message-actions 0.0.18 → 0.0.19-alpha.1

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.
Files changed (45) hide show
  1. package/README.md +29 -98
  2. package/dist/client.cjs +1380 -0
  3. package/dist/client.d.cts +6 -0
  4. package/dist/client.d.mts +6 -0
  5. package/dist/index.d.cts +75 -0
  6. package/dist/index.d.mts +3 -33
  7. package/dist/index.mjs +460 -2
  8. package/dist/invariant.d.cts +7 -0
  9. package/dist/{plan-BSa7k9HO.d.mts → plan-CT_6Y1fD.d.cts} +11 -9
  10. package/dist/plan-Cm9hCord.d.mts +101 -0
  11. package/dist/plan.d.cts +2 -0
  12. package/dist/plan.d.mts +2 -2
  13. package/dist/plan.mjs +145 -11
  14. package/dist/shared-LK3nImWW.mjs +55 -0
  15. package/dist/testing.d.cts +3242 -0
  16. package/dist/testing.d.mts +11 -5
  17. package/dist/testing.mjs +1976 -3775
  18. package/package.json +34 -31
  19. package/src/client/chat-node/MessageIconActions.styles.ts +76 -0
  20. package/src/client/chat-node/MessageIconActions.tsx +30 -11
  21. package/src/client/chat-node/MessageItem.styles.ts +247 -0
  22. package/src/client/chat-node/MessageItem.tsx +32 -39
  23. package/src/client/chat-node/message-chrome.ts +0 -2
  24. package/src/client/chat-node/register.ts +3 -4
  25. package/src/client/chat-node/use-calendar-day.ts +0 -4
  26. package/src/client/composer-stats/StatsPills.styles.ts +57 -0
  27. package/src/client/composer-stats/StatsPills.tsx +384 -0
  28. package/src/client/composer-stats/register.ts +17 -0
  29. package/src/client/composer-stats/stat-dialog.styles.ts +70 -0
  30. package/src/client/composer-stats/stat-dialog.ts +52 -0
  31. package/src/client/composer-stats/token-format.ts +85 -0
  32. package/src/client/composer-stats/turn-reading.ts +35 -0
  33. package/src/client/controller.ts +32 -114
  34. package/src/client/index.ts +8 -24
  35. package/src/index.ts +105 -118
  36. package/src/plan.ts +189 -24
  37. package/src/shared.ts +1 -1
  38. package/src/testing.ts +1 -5
  39. package/src/types.ts +1 -1
  40. package/dist/client.js +0 -806
  41. package/dist/src-B4HwXsob.mjs +0 -499
  42. package/src/client/chat-node/MessageIconActions.module.css +0 -86
  43. package/src/client/chat-node/MessageItem.module.css +0 -290
  44. package/src/client/css-modules.d.ts +0 -4
  45. package/src/client/import-action.tsx +0 -74
package/src/index.ts CHANGED
@@ -33,10 +33,12 @@ import {
33
33
  } from "./shared.ts";
34
34
  import {
35
35
  closedTurns,
36
+ droppedCompactions,
36
37
  editableMessages,
37
38
  editPlan,
38
39
  precedingContentIndex,
39
40
  recallBoundary,
41
+ restoreCompactions,
40
42
  retryPlan,
41
43
  rerollPlan,
42
44
  retryableTurns,
@@ -69,9 +71,9 @@ declare module "@deepseek-ai/cordis" {
69
71
  export interface EditorAgent {
70
72
  readonly session: Session;
71
73
  followup(message: UserMessage): void;
72
- /** 等待 agent 到达 quiescence(当前 turn/任务结束后 resolve)。 */
74
+
73
75
  whenIdle(): Promise<void>;
74
- // 停止运行中的 loop(上游 Agent.cancel 的 duck-typed 面);缺省表示实现无取消能力。
76
+
75
77
  cancel?(cause: AgentCancelCause, options?: { keepInbox?: boolean }): void;
76
78
  }
77
79
 
@@ -154,23 +156,25 @@ function appendManualTurn(
154
156
  });
155
157
  }
156
158
 
159
+ // 边界事件的保留语义(与 session-branch 的 rewind 一致):turn/end 保留边界事件本身,user/message 丢掉它自己。
160
+ function keepFromOf(events: readonly SessionEvent[], boundary: number): number {
161
+ if (boundary < 0) return 0;
162
+ return events[boundary]?.type === "turn/end" ? boundary + 1 : boundary;
163
+ }
164
+
157
165
  async function appendSeedSuffixLive(
158
166
  session: Session,
159
167
  seedSuffix: readonly SessionEvent[],
160
168
  appendDirect: (events: readonly SessionEvent[]) => Promise<void>,
161
169
  ): Promise<void> {
162
170
  for (const event of seedSuffix) {
163
- // 版本效果事件携带 ignorable 标记(上游类型无此字段,duck-type 读取)。
164
171
  const ignorable = (event as { ignorable?: boolean }).ignorable === true;
165
172
  if (ignorable) {
166
173
  const s = session as unknown as {
167
174
  log: SessionEvent[];
168
175
  eventsSnapshot?: unknown;
169
176
  };
170
- // 原样存储语义:版本效果事件经 RDB write handle 直接落库(带
171
- // ignorable 信封,与 JSONL 一致)。session.append 不保留 ignorable
172
- // 标记且类型系统禁止 append 未知类型,因此不发布、直接 push 内存
173
- // log(seq 按 log 续接重编号,与 handle cursor 同空间)。
177
+
174
178
  const seq = s.log.length;
175
179
  await appendDirect([{ ...event, seq } as SessionEvent]);
176
180
  s.log.push({ ...event, seq } as SessionEvent);
@@ -204,8 +208,7 @@ export class SessionEditor extends Service {
204
208
 
205
209
  constructor(ctx: Context) {
206
210
  super(ctx, "sessionEditor");
207
- // HTTP 路由随类构造注册(dsh 用 default 类插件,apply 不被调用);
208
- // bundles 顺序保证 webserver 先于本类实例化。
211
+
209
212
  registerHttpRoutes(ctx);
210
213
  }
211
214
 
@@ -287,14 +290,12 @@ export class SessionEditor extends Service {
287
290
  : operation.action === "retry"
288
291
  ? retryPlan(operation, turns)
289
292
  : rerollPlan(operation, turns);
290
- // rewind 前解析模型配置:就地编辑可能截断最后的 request/header
291
- // (编辑第一轮 boundary = -1 清空全部),重放 agent 需要 provider/model。
293
+
292
294
  const headerConfig = events.findLast((event) => event.type === "request/header")?.data.header
293
295
  .config;
294
296
 
295
297
  const turnIndex = turns.findIndex((turn) => turn.startSeq === plan.anchorSeq);
296
- // 空轮吸收:目标轮之前的空轮(早期重放缺陷的遗留)随截断一并删除,
297
- // 重放按保留前缀续号——轮次导航不再出现点不开的空项与轮号空洞。
298
+
298
299
  const preceding = precedingContentIndex(turns, turnIndex);
299
300
  const boundary =
300
301
  plan.rewindBoundary !== undefined
@@ -302,21 +303,19 @@ export class SessionEditor extends Service {
302
303
  : preceding < 0
303
304
  ? -1
304
305
  : turns[preceding]!.endSeq!;
305
- // manualTurn 的轮号同样按保留前缀续号:正常会话等于目标轮号,有空轮
306
- // 时收敛为连续编号。
306
+ // 补回被截断丢掉、但在重放目标之前的压缩
307
+ const keepFrom = keepFromOf(events, boundary);
308
+ const compactions = droppedCompactions(events, keepFrom, plan.targetSeq);
309
+
307
310
  const replayTurn = preceding < 0 ? 1 : turns[preceding]!.turn + 1;
308
311
 
309
- // 派生 seed 后缀:版本效果 + 可选手工回合。版本事件对核心是 ignorable,
310
- // 保证非 branch 读者可安全跳过。
311
- const seedSuffix: SessionEvent[] = [];
312
- appendLogSeedEvent(seedSuffix, "session-branch/version", plan.version, true);
312
+ const versionSeed: SessionEvent[] = [];
313
+ appendLogSeedEvent(versionSeed, "session-branch/version", plan.version, true);
314
+ const manualSeed: SessionEvent[] = [];
313
315
  if (plan.manualTurn !== undefined) {
314
- appendManualTurn(seedSuffix, { ...plan.manualTurn, turn: replayTurn });
316
+ appendManualTurn(manualSeed, { ...plan.manualTurn, turn: replayTurn });
315
317
  }
316
318
 
317
- // 就地编辑:不创建新会话、不改变 id。需要重放排队输入时,先在 rewind
318
- // 前确保 agent 就绪(cold 先 resume;live 复用驻留 agent)——rewind 截断后
319
- // agent 无法再以完整会话 resume,且重放失败不应让截断静默丢弃内容。
320
319
  const replay = await this.prepareReplay(
321
320
  operation.sessionId,
322
321
  plan.queuedUsers,
@@ -324,64 +323,20 @@ export class SessionEditor extends Service {
324
323
  headerConfig,
325
324
  );
326
325
 
327
- // rewind 前必须停止运行中的 loop:截断会重写 agent 的 session 内存 log。
328
326
  await this.stopLoop(operation.sessionId, signal);
329
327
  const live = this.ctx.sessions.get(operation.sessionId);
330
328
  await this.ctx.sessionBranch.rewind(operation.sessionId, boundary, signal);
331
- if (seedSuffix.length > 0) {
332
- if (live !== undefined) {
333
- // live:版本效果经 RDB write handle 直接落库(带 ignorable 信封,
334
- // 与 JSONL 一致)、manualTurn 走 append;同步 cursor 后显式 flush
335
- // (直接落库不发布、不进缓冲,cursor 会落后)。
336
- const persistence = this.ctx.sessionPersistence as unknown as {
337
- tracker: {
338
- writerOf(id: SessionId):
339
- | {
340
- append(events: readonly SessionEvent[]): Promise<void>;
341
- }
342
- | undefined;
343
- };
344
- };
345
- const handle = persistence.tracker.writerOf(operation.sessionId);
346
- if (handle === undefined) {
347
- throw new Error(
348
- `session "${operation.sessionId}" has no live write handle for the version effect`,
349
- );
350
- }
351
- // rewind 截断后可能已追加事件(如 inbox 取消 splice),它们仍在
352
- // write-behind 缓冲里;直接对 handle 写版本效果前必须先把缓冲落盘,
353
- // 否则 handle cursor 落后于 log 长度 → append seq mismatch。
354
- await this.ctx.sessions.flush(live);
355
- await appendSeedSuffixLive(live, seedSuffix, (events) => handle.append(events));
356
- await this.ctx.sessions.flush(live);
357
- } else {
358
- // cold:续写 seq 从平衡后的保留前缀接续(exclusive 截断可能残留
359
- // 孤儿 step/start,落盘 log 对 token meter 重放非法)。
360
- const rawKeepLength = boundary + (plan.rewindBoundary === undefined ? 1 : 0);
361
- const keepLength = balanceRewindPrefix(events.slice(0, rawKeepLength)).length;
362
- const renumbered = seedSuffix.map(
363
- (event, index) =>
364
- ({
365
- ...event,
366
- seq: keepLength + index,
367
- }) as SessionEvent,
368
- );
369
- const handle = await this.ctx.sessionPersistence.open(operation.sessionId, "write");
370
- try {
371
- await handle.append(renumbered);
372
- } finally {
373
- await handle.close();
374
- }
375
- }
376
- }
329
+ // 补回的压缩落在版本效果之后、重放的输入之前
330
+ const seedStart = this.seedStart(live, events, keepFrom);
331
+ const seedSuffix: SessionEvent[] = [
332
+ ...versionSeed,
333
+ ...restoreCompactions(compactions, keepFrom, seedStart + versionSeed.length),
334
+ ...manualSeed,
335
+ ];
336
+ await this.appendSeedSuffix(operation.sessionId, live, seedSuffix, events, keepFrom);
377
337
 
378
- // 发起新的 user prompt:把排队输入交给就绪的 agent(rewind 后其 session
379
- // 已被截断,followup 基于截断后历史开新轮重放)。无 agent(agents 服务
380
- // 缺失)时退化为已 durable 的就地版本。
381
338
  let queuedTurns = 0;
382
339
  if (replay.agent !== undefined && plan.queuedUsers.length > 0) {
383
- // seedSuffix 里的 manualTurn 直接写入了 turn/start,而 agent 的轮次
384
- // 游标仍停在 rewind 重置的位置;不同步就会把重放开成重复轮号。
385
340
  const lastSeedTurn = seedSuffix.findLast((event) => event.type === "turn/start")?.data.turn;
386
341
  if (lastSeedTurn !== undefined) {
387
342
  const phase = (replay.agent as unknown as { phase?: { lastTurn?: number } }).phase;
@@ -394,19 +349,11 @@ export class SessionEditor extends Service {
394
349
  return {
395
350
  sessionId: operation.sessionId,
396
351
  queuedTurns,
397
- // 操作后是否仍有 live owner(prepareReplay 可能 resume 出 agent)。
352
+
398
353
  live: this.ctx.sessions.get(operation.sessionId) !== undefined,
399
354
  };
400
355
  }
401
356
 
402
- /**
403
- * 撤回(recall):只 rewind 截断,不重写、不重放——被撤回的 user 消息文本
404
- * 由客户端回填到输入框,交给用户修改后重新发送。
405
- *
406
- * 边界见 {@link recallBoundary}:轮首消息整轮截断到前一轮 `turn/end`
407
- * (无前轮则 -1),轮内 followup 与轮外 user 消息只截断到该消息本身
408
- * (exclusive drop),避免留下悬空 `turn/start` 让下一次发送开到错误轮号。
409
- */
410
357
  private async recallOperation(
411
358
  operation: RecallOperation,
412
359
  signal?: AbortSignal,
@@ -414,9 +361,20 @@ export class SessionEditor extends Service {
414
361
  signal?.throwIfAborted();
415
362
  const events = await this.readEvents(operation.sessionId, signal);
416
363
  const boundary = recallBoundary(events, closedTurns(events), operation.eventSeq);
417
- // rewind 前必须停止运行中的 loop:截断会重写 agent 的 session 内存 log。
364
+ // 撤回同样要补回被丢掉、但在撤回目标之前的压缩
365
+ const keepFrom = keepFromOf(events, boundary);
366
+ const compactions = droppedCompactions(events, keepFrom, operation.eventSeq);
367
+
418
368
  await this.stopLoop(operation.sessionId, signal);
369
+ const live = this.ctx.sessions.get(operation.sessionId);
419
370
  await this.ctx.sessionBranch.rewind(operation.sessionId, boundary, signal);
371
+ await this.appendSeedSuffix(
372
+ operation.sessionId,
373
+ live,
374
+ restoreCompactions(compactions, keepFrom, this.seedStart(live, events, keepFrom)),
375
+ events,
376
+ keepFrom,
377
+ );
420
378
  return {
421
379
  sessionId: operation.sessionId,
422
380
  queuedTurns: 0,
@@ -424,9 +382,62 @@ export class SessionEditor extends Service {
424
382
  };
425
383
  }
426
384
 
427
- // rewind 前停止运行中的 loop:截断会重写 agent 的 session 内存 log;无
428
- // live agent 时跳过,实现无取消能力时退化为等待其自然停下(keepInbox
429
- // 保留排队输入,截断后由 rdb 的 live 钩子 durable 取消)。
385
+ // 种子后缀的起点 seq:live 会话是被截断后的日志长度,cold 会话是配平后的保留前缀长度
386
+ private seedStart(
387
+ live: Session | undefined,
388
+ events: readonly SessionEvent[],
389
+ keepFrom: number,
390
+ ): number {
391
+ return live === undefined
392
+ ? balanceRewindPrefix(events.slice(0, keepFrom)).length
393
+ : Number(live.seq);
394
+ }
395
+
396
+ private async appendSeedSuffix(
397
+ sessionId: SessionId,
398
+ live: Session | undefined,
399
+ seedSuffix: readonly SessionEvent[],
400
+ events: readonly SessionEvent[],
401
+ keepFrom: number,
402
+ ): Promise<void> {
403
+ if (seedSuffix.length === 0) return;
404
+ if (live !== undefined) {
405
+ const persistence = this.ctx.sessionPersistence as unknown as {
406
+ tracker: {
407
+ writerOf(id: SessionId):
408
+ | {
409
+ append(events: readonly SessionEvent[]): Promise<void>;
410
+ }
411
+ | undefined;
412
+ };
413
+ };
414
+ const handle = persistence.tracker.writerOf(sessionId);
415
+ if (handle === undefined) {
416
+ throw new Error(`session "${sessionId}" has no live write handle for the version effect`);
417
+ }
418
+
419
+ await this.ctx.sessions.flush(live);
420
+ await appendSeedSuffixLive(live, seedSuffix, (batch) => handle.append(batch));
421
+ await this.ctx.sessions.flush(live);
422
+ return;
423
+ }
424
+
425
+ const keepLength = balanceRewindPrefix(events.slice(0, keepFrom)).length;
426
+ const renumbered = seedSuffix.map(
427
+ (event, index) =>
428
+ ({
429
+ ...event,
430
+ seq: keepLength + index,
431
+ }) as SessionEvent,
432
+ );
433
+ const handle = await this.ctx.sessionPersistence.open(sessionId, "write");
434
+ try {
435
+ await handle.append(renumbered);
436
+ } finally {
437
+ await handle.close();
438
+ }
439
+ }
440
+
430
441
  private async stopLoop(sessionId: SessionId, signal?: AbortSignal): Promise<void> {
431
442
  signal?.throwIfAborted();
432
443
  const agents = this.ctx.get("agents") as EditorAgentRegistry | undefined;
@@ -443,8 +454,7 @@ export class SessionEditor extends Service {
443
454
  ): Promise<readonly SessionEvent[]> {
444
455
  const live = this.ctx.sessions.get(sessionId);
445
456
  if (live !== undefined) return live.snapshotEvents();
446
- // cold:读原始事件(loadStored 不补 closers)——inspect 会把未闭合 log
447
- // 补成闭合,编辑未闭合轮次的 user 消息会走错边界。
457
+
448
458
  const branch = this.ctx.sessionBranch as unknown as {
449
459
  readRawEvents(
450
460
  id: SessionId,
@@ -454,10 +464,6 @@ export class SessionEditor extends Service {
454
464
  return (await branch.readRawEvents(sessionId, signal)).events;
455
465
  }
456
466
 
457
- // rewind 前确保 agent 可驱动重放:cold 会话先 resume 出驻留 agent(此时会话
458
- // 完整,resume 的 prepare 不与截断冲突);live 会话复用驻留 agent,停止其
459
- // 运行中的 loop 由 rewind 前的 stopLoop 统一负责。agents 服务缺失或无需
460
- // 重放时返回空——调用方退化为就地截断版本。
461
467
  private async prepareReplay(
462
468
  sessionId: SessionId,
463
469
  queuedUsers: readonly UserMessage[],
@@ -470,16 +476,12 @@ export class SessionEditor extends Service {
470
476
  if (agents === undefined) return { agent: undefined };
471
477
  const existing = agents.get(sessionId);
472
478
  if (existing !== undefined) {
473
- // 排队输入由 rewind 在截断后强制 durable 取消(session-rdb 的
474
- // LiveSessionHooks.inbox.clear),这里只需复用驻留 agent。
475
479
  return { agent: existing };
476
480
  }
477
- // cold:resume 已持久化会话(create 会因「已存在持久化日志」失败)。
478
- // resume 失败是硬错误:rewind 尚未发生,编辑保持原子(不截断不丢数据)。
481
+
479
482
  const provider = headerConfig?.provider ?? "";
480
483
  const model = headerConfig?.model ?? "";
481
484
  if (provider.length === 0 || model.length === 0) {
482
- // 兜底:从当前会话 events 解析(headerConfig 未提供时)。
483
485
  const events = await this.readEvents(sessionId, signal);
484
486
  const config = events.findLast((event) => event.type === "request/header")?.data.header
485
487
  .config;
@@ -506,11 +508,6 @@ export class SessionEditor extends Service {
506
508
 
507
509
  export default SessionEditor;
508
510
 
509
- // ---------------------------------------------------------------------------
510
- // HTTP 面(host):GET /session-editor(timeline 投影)/ POST /session-editor
511
- // (edit | reroll | retry | rewind | fork)。
512
- // ---------------------------------------------------------------------------
513
-
514
511
  interface HttpRequestLike {
515
512
  method?: string;
516
513
  url?: string;
@@ -532,12 +529,6 @@ interface HttpServerLike {
532
529
  }): () => void;
533
530
  }
534
531
 
535
- declare module "@deepseek-ai/cordis" {
536
- interface Context {
537
- webServer: HttpServerLike;
538
- }
539
- }
540
-
541
532
  function objectValue(value: unknown): Record<string, unknown> {
542
533
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
543
534
  throw new TypeError("请求体必须是 JSON 对象。");
@@ -733,10 +724,10 @@ async function handleRoute(
733
724
  }
734
725
 
735
726
  function registerHttpRoutes(ctx: Context): void {
736
- // web 模式:注册到 webServer(dsh web 的 HTTP 面)。服务在构造期可能
737
- // 尚未就绪,effect 回调在插件激活后执行,此时 ctx.get 才能取到。
738
727
  ctx.effect(() => {
739
- const webServer = ctx.get("webServer") as HttpServerLike | undefined;
728
+ // webServer 的类型由上游 @deepseek-ai/dsh-host-webserver 声明(`Context.webServer: WebServer`);
729
+ // 这里只按用到的 register 面做结构转换,不再 declare module 覆盖,否则两处声明冲突(TS2717)。
730
+ const webServer = ctx.get("webServer") as unknown as HttpServerLike | undefined;
740
731
  if (webServer === undefined) return () => {};
741
732
  const editor = ctx.sessionEditor;
742
733
  return webServer.register({
@@ -745,10 +736,7 @@ function registerHttpRoutes(ctx: Context): void {
745
736
  handler: (request, response) => handleRoute(editor, request, response),
746
737
  });
747
738
  }, "session-editor: HTTP route");
748
- // desktop 模式:注册到 connection 的共享 /api 通道(官方 desktop 禁用
749
- // webserver,走 connection 网关;路径带 /api 前缀)。connection 是可选
750
- // 服务(web 模式无),且可能在 SessionEditor 构造前已 provide(事件已
751
- // 错过)——监听 internal/service 事件并立即检查一次。
739
+
752
740
  const registerConnectionRoute = (): void => {
753
741
  const connection = ctx.get("connection", false) as
754
742
  | {
@@ -781,7 +769,6 @@ function registerHttpRoutes(ctx: Context): void {
781
769
  registerConnectionRoute();
782
770
  }
783
771
 
784
- /** connection.fetch 路由的 Fetch 形态处理(与 webServer 的 node:http 形态同语义)。 */
785
772
  async function handleFetchRoute(editor: SessionEditor, request: Request): Promise<Response> {
786
773
  try {
787
774
  if (request.method === "GET") {