dsh-edit-turn 0.2.0 → 0.2.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.
package/README.md CHANGED
@@ -110,11 +110,12 @@ await ctx.sessionController.prompt({ requestId, sessionId, mode: 'queue', conten
110
110
 
111
111
  ### 验证状态
112
112
 
113
- 本插件在 DSH `0.1.6-alpha.2` 上完成以下验证(全部**零模型调用**):
113
+ 本插件在 DSH `0.1.6-alpha.2` 与 `0.1.7-rc.1` 上完成以下验证:
114
114
 
115
115
  | 验证 | 命令 | 结果 |
116
116
  |---|---|---|
117
117
  | 运行中的实例是否真的挂载了本插件(只读路由守卫探针,无需 token) | `npm run probe:loaded [端口]` | 通过:`/state` 返 400、`/apply` 返 405 —— 这两个状态码只有本插件会返回 |
118
+ | 浏览器半部到底问了什么、拿回了什么(只读诊断,含失败记录) | `GET /dsh-edit-turn/debug` | 通过:真实浏览器里确认客户端确实拿到 replies;并复现了旧 v3 会话的 session-not-found |
118
119
  | 官方 append 契约(真实校验器,进程内) | `npm run verify:contract` | 61 项通过:替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 system 载体不产生模型消息、**连续两次回退都被接受**、**编辑模型回答的完整机制被接受** |
119
120
  | 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 83 项通过 |
120
121
  | 客户端半部静态检查(注册、i18n 完整性、样式、皮肤可读性、版本三处同步、线协议) | `npm run verify:client` | 全部通过 |
@@ -149,6 +150,22 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
149
150
  - **进行中拒绝编辑**:未闭合的轮次或正在压缩时返回 `409 busy`。
150
151
  - **回退是持久的,隐藏不是**。回退写进日志后,模型上下文永久改变;转录里那些行的隐藏是本插件客户端半部做的。卸载插件后,旧行会重新显示出来(而模型上下文里的回退仍然生效)——因为替换事件是官方事件,不会随插件消失。
151
152
  - 回退后**系统提示词保持不变**(窗口永不包含 surface 节点 0,该节点也永不可编辑)。
153
+ - **旧会话(v3 格式日志)在 DSH 0.1.7 下读不到**:DSH 的会话读取层对这类日志返回 `session-not-found`,本插件因此在其中完全不工作(表现为没有编辑入口)。新写的会话是 v4,正常。
154
+
155
+ ### 更新日志
156
+
157
+ **0.2.1** —— 修 0.1.7 上的两处界面问题,并补上让问题可自证的诊断手段。
158
+
159
+ - 修复:模型回复的编辑入口改注册进官方插槽 `conversation.chat.assistant-actions`,与平台自己的 复制 / 赞 / 踩 / 分支 / 用量 同排(此前是 DOM 注入,位置不一致、且会随工具调用行卸载而闪烁)。
160
+ - 修复:编辑模型回答后**重新闭合该轮**(`step/end` + `turn/end`)。此前修正消息被追加到 `turn/end` 之后、落在轮外,导致那一轮的尾巴(用时、动作条)渲染到回复正文**上面**。
161
+ - 修复:DSH 0.1.7 兼容——会话格式 v4 的 producer-owned source kind(`plugin:<id>`)、助手消息文本取自 `data.message.content`。
162
+ - 新增:只读诊断路由 `GET /dsh-edit-turn/debug`;`npm run probe:loaded [端口]`。
163
+
164
+ **0.2.0** —— 可以编辑模型的回答。回退该回答及其后内容,再追加一条带改写文本的助手消息(回答无法原地替换:官方格式禁止 `assistant/message` 携带 `sourceEventSeqs`)。不重跑模型;`source.editedBy` 如实标记文字由插件写入。
165
+
166
+ **0.1.1** —— 去掉多余的二次确认(默认一次点击即执行);修皮肤下界面可读性。
167
+
168
+ **0.1.0** —— 首版:回退并重跑你自己发过的消息。
152
169
 
153
170
  ### 姊妹插件
154
171
 
@@ -158,7 +175,7 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
158
175
 
159
176
  ### 兼容性
160
177
 
161
- - `dsh.engines.dsh`: `>=0.1.6-alpha.2`(本插件依赖该版本的消息行定义与 surface 语义,并已在此版本实测)。
178
+ - `dsh.engines.dsh`: `>=0.1.6-alpha.2`(本插件依赖该版本的消息行定义与 surface 语义;`0.1.6-alpha.2` 与 `0.1.7-rc.1` 均已实测)。
162
179
  - 客户端依赖:`dsh-client-locale`、`dsh-client-ui-chat`、`dsh-client-ui-conversation`、`dsh-client-ui-primitives`。
163
180
  - 宿主依赖:`dsh-settings`、`dsh-tools`(peer);`@deepseek-ai/schemastery`(直接依赖)。
164
181
 
package/lib/client.js CHANGED
@@ -30,7 +30,7 @@ window.__ModuleLoader__.load({
30
30
  const ROUTE_PREFIX = '/dsh-edit-turn'
31
31
 
32
32
  /** Keep in sync with package.json and lib/index.js. */
33
- const PLUGIN_VERSION = '0.2.0'
33
+ const PLUGIN_VERSION = '0.2.1'
34
34
 
35
35
  // --- copy -----------------------------------------------------------------
36
36
 
@@ -216,6 +216,7 @@ window.__ModuleLoader__.load({
216
216
  hidden: new Map(),
217
217
  editable: new Map(),
218
218
  replies: new Map(),
219
+ repliesByMessage: new Map(),
219
220
  editing: null,
220
221
  draft: '',
221
222
  confirming: false,
@@ -278,13 +279,19 @@ window.__ModuleLoader__.load({
278
279
  // rewritten rather than re-run, so the editor words the consequence
279
280
  // differently.
280
281
  const replies = new Map()
282
+ const repliesByMessage = new Map()
281
283
  for (const reply of Array.isArray(data.replies) ? data.replies : []) {
282
- if (reply && typeof reply.seq === 'number') replies.set(reply.seq, reply)
284
+ if (!reply || typeof reply.seq !== 'number') continue
285
+ replies.set(reply.seq, reply)
286
+ // The official assistant-actions strip hands us a messageId, so
287
+ // replies are indexed by it too.
288
+ if (typeof reply.messageId === 'string' && reply.messageId !== '') repliesByMessage.set(reply.messageId, reply)
283
289
  }
284
290
  this.publish({
285
291
  hidden,
286
292
  editable,
287
293
  replies,
294
+ repliesByMessage,
288
295
  surfaceReady: true,
289
296
  loaded: true,
290
297
  loadError: false,
@@ -431,34 +438,26 @@ window.__ModuleLoader__.load({
431
438
  // The editable target of a Chat view node, or null when the row stands for
432
439
  // something that cannot be rewritten.
433
440
  //
434
- // Two kinds are offered: a human prompt (rewritten, then the turn re-runs)
435
- // and a model reply (rewritten in place, discarding everything after it).
436
- // A step row can span several surface nodes, and the reply seq sits on
437
- // `finalNode` for an assistant step while a user row carries it on `data`,
438
- // so both candidates are tried rather than assumed.
441
+ // Only human prompts come through here: a reply's entry comes from the
442
+ // official assistant-actions strip instead, which is where the host puts
443
+ // every other action for that row. Injecting one here used to put the pencil
444
+ // in a different place from every other reply action.
439
445
  function targetFor(node, view) {
440
- if (!node) return null
446
+ if (!node || node.kind !== 'user') return null
441
447
  const data = node.data || {}
442
448
  const candidates = []
443
449
  const push = (value) => {
444
450
  if (typeof value === 'number' && !candidates.includes(value)) candidates.push(value)
445
451
  }
446
- if (node.kind === 'user') {
447
- push(data.seq)
448
- push(node.anchorSeq)
449
- } else if (node.kind === 'assistant-step') {
450
- push(data.finalNode && data.finalNode.seq)
451
- push(node.anchorSeq)
452
- } else {
453
- return null
454
- }
452
+ push(data.seq)
453
+ push(node.anchorSeq)
455
454
  for (const seq of candidates) {
456
455
  if (view.hidden.has(seq)) continue
457
- const entry = view.editable.get(seq) || view.replies.get(seq)
456
+ const entry = view.editable.get(seq)
458
457
  if (!entry) continue
459
458
  return {
460
459
  seq,
461
- mode: view.editable.has(seq) ? 'prompt' : 'reply',
460
+ mode: 'prompt',
462
461
  turn: entry.turn,
463
462
  messageId: entry.messageId,
464
463
  text: typeof entry.text === 'string' ? entry.text : '',
@@ -468,6 +467,13 @@ window.__ModuleLoader__.load({
468
467
  return null
469
468
  }
470
469
 
470
+ // The element that carries the turn-tail marker: either the flow-keyed row
471
+ // itself or a wrapper inside it. Its own action strip is a direct child.
472
+ function rootOfTurnTail(row) {
473
+ if (typeof row.matches === 'function' && row.matches('[data-turn-tail]')) return row
474
+ return row.querySelector('[data-turn-tail]')
475
+ }
476
+
471
477
  // Every surface seq a row stands for, so one rollback can hide the rows it
472
478
  // shadowed however the host UI chose to group them.
473
479
  function seqsFor(node) {
@@ -559,7 +565,7 @@ window.__ModuleLoader__.load({
559
565
  rowActions.delete(row)
560
566
  }
561
567
 
562
- function injectRowAction(row, target, controller, t) {
568
+ function injectRowAction(row, target, controller, t, options = {}) {
563
569
  const label = t(target.mode === 'reply' ? 'action.edit-reply' : 'action.edit')
564
570
  let entry = rowActions.get(row)
565
571
  if (!entry) {
@@ -569,6 +575,11 @@ window.__ModuleLoader__.load({
569
575
  button.type = 'button'
570
576
  button.className = 'dshet-action dshet-row-action'
571
577
  button.innerHTML = ICON_MARKUP
578
+ // The marker goes on the BUTTON, not only on its wrapper: the pass that
579
+ // looks for the official strip's entry queries buttons, so a marker one
580
+ // level up made this pencil look like the strip's own and it deleted
581
+ // itself - then the next pass put it back, which reads as flicker.
582
+ if (options.fallback === true) button.dataset.dshetFallback = '1'
572
583
  host.appendChild(button)
573
584
  entry = { host, button }
574
585
  rowActions.set(row, entry)
@@ -583,7 +594,9 @@ window.__ModuleLoader__.load({
583
594
  event.stopPropagation()
584
595
  controller.open(target)
585
596
  }
586
- const anchor = row.querySelector('[class*="_actions"]')
597
+ // A caller that already picked the right bar (the turn tail's own strip)
598
+ // hands it over; otherwise the first actions container in the row wins.
599
+ const anchor = options.anchor ?? row.querySelector('[class*="_actions"]')
587
600
  if (anchor) {
588
601
  host.classList.remove('dshet-floating')
589
602
  row.classList.remove('dshet-row')
@@ -754,14 +767,62 @@ window.__ModuleLoader__.load({
754
767
  const hidden = isRowHidden(view.hidden, seqsFor(node))
755
768
  setRowHidden(row, hidden, animate)
756
769
  const target = hidden ? null : targetFor(node, view)
757
- if (target !== null) injectRowAction(row, target, controller, t)
758
- else removeRowAction(row)
770
+ if (target !== null) {
771
+ injectRowAction(row, target, controller, t)
772
+ } else if (!hidden && node.kind === 'turn-tail') {
773
+ // A reply's entry belongs in the host's own action strip, and that is
774
+ // where it is registered. A slot entry that never renders leaves no
775
+ // trace at all, though - no error, no gap - so the same pencil is also
776
+ // offered through the turn tail's own strip, and only while that strip
777
+ // does not already carry one. Both land in the platform's own bar, so
778
+ // the position is identical either way.
779
+ const covered = seqsFor(node).find((seq) => view.replies.has(seq) && !view.hidden.has(seq))
780
+ // The strip lives inside the turn-tail root (`data-turn-tail`) as its
781
+ // LAST direct child, while the row carrying the flow key may be a
782
+ // wrapper around that root. Bounding the search by the tail root keeps
783
+ // the tool-call bars inside the tail out of reach: those unmount as a
784
+ // call expands, so a pencil parked there blinks, and it is not where
785
+ // the reply's actions live anyway.
786
+ const tailRoot = rootOfTurnTail(row)
787
+ const bar =
788
+ covered === undefined || tailRoot === null
789
+ ? undefined
790
+ : Array.from(tailRoot.children)
791
+ .reverse()
792
+ .find((child) => typeof child.className === 'string' && child.className.includes('_actions'))
793
+ const slotPencil = bar === undefined ? null : bar.querySelector('.dshet-row-action:not([data-dshet-fallback])')
794
+ if (covered !== undefined && bar !== undefined && slotPencil === null) {
795
+ const entry = view.replies.get(covered)
796
+ injectRowAction(
797
+ row,
798
+ {
799
+ seq: covered,
800
+ mode: 'reply',
801
+ turn: entry.turn,
802
+ messageId: entry.messageId,
803
+ text: typeof entry.text === 'string' ? entry.text : '',
804
+ attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
805
+ },
806
+ controller,
807
+ t,
808
+ { fallback: true, anchor: bar },
809
+ )
810
+ } else {
811
+ // Either the strip's own entry is already there, or this row holds
812
+ // nothing editable: any spare pencil of ours has to go.
813
+ removeRowAction(row)
814
+ }
815
+ } else {
816
+ removeRowAction(row)
817
+ }
818
+ // The editor anchors to whichever row covers the seq being edited, not to
819
+ // the row that owns the entry: a reply's entry now lives in the turn-tail
820
+ // action strip, while its editor still belongs under the reply text.
759
821
  if (
760
822
  !hidden &&
761
823
  view.editing !== null &&
762
824
  typeof view.editing.seq === 'number' &&
763
- target !== null &&
764
- target.seq === view.editing.seq
825
+ seqsFor(node).includes(view.editing.seq)
765
826
  ) {
766
827
  renderEditor(row, controller, view, t)
767
828
  }
@@ -769,6 +830,40 @@ window.__ModuleLoader__.load({
769
830
  if (view.editing === null) clearEditor()
770
831
  }
771
832
 
833
+ // The reply edit entry, registered in the host's own assistant-actions strip.
834
+ //
835
+ // It has to live there rather than be injected next to the message: the host
836
+ // renders that strip per assistant message, hands the entry the messageId, and
837
+ // keeps the platform's own reveal behaviour (always for the latest turn, hover
838
+ // for older ones). Injecting instead put the pencil in a different place from
839
+ // where every other action for that row lives, which is what users noticed.
840
+ function ReplyActionEntry({ messageId, controller, useEditTurn, t }) {
841
+ const view = useEditTurn((state) => state)
842
+ const entry = view.repliesByMessage.get(messageId)
843
+ if (entry === undefined || view.hidden.has(entry.seq)) return null
844
+ const label = t('action.edit-reply')
845
+ const open = (event) => {
846
+ event.preventDefault()
847
+ event.stopPropagation()
848
+ controller.open({
849
+ seq: entry.seq,
850
+ mode: 'reply',
851
+ turn: entry.turn,
852
+ messageId: entry.messageId,
853
+ text: typeof entry.text === 'string' ? entry.text : '',
854
+ attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
855
+ })
856
+ }
857
+ return jsx('button', {
858
+ type: 'button',
859
+ className: 'dshet-action dshet-row-action',
860
+ 'aria-label': label,
861
+ title: label,
862
+ onClick: open,
863
+ children: jsx(PencilIcon, null),
864
+ })
865
+ }
866
+
772
867
  // --- react entries --------------------------------------------------------
773
868
 
774
869
  function OverlayEntry({ useChat, useEditTurn, controller, t }) {
@@ -835,6 +930,25 @@ window.__ModuleLoader__.load({
835
930
  'dsh-edit-turn: per-session controllers',
836
931
  )
837
932
 
933
+ // A reply's edit entry lives in the official assistant-actions strip, one
934
+ // entry per assistant message. order 5 puts it ahead of the feedback
935
+ // entries (order 10), so the pencil sits with the leading icons.
936
+ ctx.slots.inject('conversation.chat.assistant-actions', () =>
937
+ ctx.slots.register(
938
+ {
939
+ name: 'conversation.chat.assistant-actions',
940
+ id: 'edit-turn-reply',
941
+ order: 5,
942
+ locale: NS,
943
+ inject: (sessionId) => ({
944
+ hooks: { editTurn: controllerFor(sessionId) },
945
+ controller: controllerFor(sessionId),
946
+ }),
947
+ },
948
+ ReplyActionEntry,
949
+ ),
950
+ )
951
+
838
952
  ctx.slots.inject('conversation.input.overlay', () =>
839
953
  ctx.slots.register(
840
954
  {
package/lib/index.js CHANGED
@@ -48,7 +48,24 @@ export const PLUGIN_ID = 'dsh-edit-turn'
48
48
  export const name = PLUGIN_ID
49
49
 
50
50
  /** Keep in sync with package.json and lib/client.js. */
51
- export const PLUGIN_VERSION = '0.2.0'
51
+ export const PLUGIN_VERSION = '0.2.1'
52
+
53
+ // A ring of the last few requests, answering "did the browser half even ask, and
54
+ // what did it get?" without needing its console. Read-only diagnostics: the
55
+ // browser half's own state is otherwise invisible from the host side.
56
+ const REQUEST_LOG_LIMIT = 40
57
+ const requestLog = []
58
+
59
+ /** Record one request for the diagnostic route. */
60
+ export function noteRequest(entry) {
61
+ requestLog.push({ at: Date.now(), ...entry })
62
+ if (requestLog.length > REQUEST_LOG_LIMIT) requestLog.shift()
63
+ }
64
+
65
+ /** The recorded requests, oldest first. */
66
+ export function recentRequests() {
67
+ return requestLog.slice()
68
+ }
52
69
 
53
70
  const ROUTE_PREFIX = '/dsh-edit-turn'
54
71
  const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -595,16 +612,31 @@ async function flushSession(ctx, session) {
595
612
 
596
613
  async function stateOf(ctx, sessionId, config) {
597
614
  const events = await readEvents(ctx, sessionId)
598
- if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
615
+ if (!events) {
616
+ noteRequest({ kind: 'state', sessionId, ok: false, code: 'session-not-found' })
617
+ throw new HttpError(404, 'session-not-found', 'no session log for this id')
618
+ }
599
619
  const folded = foldSurface(events)
600
620
  const ledger = rollbackLedger(events)
601
621
  const live = findLiveSession(ctx, sessionId)
622
+ const turns = editableTurns(events, folded.nodes)
623
+ const replies = editableReplies(events, folded.nodes)
624
+ noteRequest({
625
+ kind: 'state',
626
+ sessionId,
627
+ ok: true,
628
+ events: events.length,
629
+ turns: turns.length,
630
+ replies: replies.length,
631
+ hidden: ledger.hidden.length,
632
+ live: Boolean(live),
633
+ })
602
634
  return {
603
635
  hidden: ledger.hidden,
604
636
  edits: ledger.edits.length,
605
637
  surface: folded.nodes,
606
- turns: editableTurns(events, folded.nodes),
607
- replies: editableReplies(events, folded.nodes),
638
+ turns,
639
+ replies,
608
640
  live: Boolean(live),
609
641
  busy: isBusy(events),
610
642
  lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
@@ -752,6 +784,13 @@ async function applyEdit(ctx, sessionId, body, config) {
752
784
  let appended
753
785
  try {
754
786
  appended = session.append(correction.type, correction.data, { surfaceOp: 'append' })
787
+ // Close the turn again, now that the corrected reply is the last thing in
788
+ // it. The host anchors a turn's tail (its duration and action strip) at the
789
+ // turn's LAST `turn/end`, so a correction appended after the old one lands
790
+ // OUTSIDE the turn and the tail renders above the corrected text. These two
791
+ // events put the tail back behind it, exactly as a normal turn ends.
792
+ session.append('step/end', { turn: appended.data.turn, step: appended.data.step })
793
+ session.append('turn/end', { turn: appended.data.turn, reason: { kind: 'completed' } })
755
794
  } catch (error) {
756
795
  return {
757
796
  kind: 'reply',
@@ -1013,6 +1052,24 @@ export function apply(ctx, config) {
1013
1052
  }),
1014
1053
  )
1015
1054
 
1055
+ // Read-only diagnostics. The browser half's own state cannot be observed
1056
+ // from here, so the next best thing is an honest record of what its requests
1057
+ // asked for and what they got back.
1058
+ fiber.effect(() =>
1059
+ webServer.register({
1060
+ kind: 'exact',
1061
+ path: `${ROUTE_PREFIX}/debug`,
1062
+ handler: (req, res) => {
1063
+ if (!guard(req, res)) return
1064
+ if (req.method !== 'GET') {
1065
+ sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
1066
+ return
1067
+ }
1068
+ sendJson(res, 200, { ok: true, version: PLUGIN_VERSION, requests: recentRequests() })
1069
+ },
1070
+ }),
1071
+ )
1072
+
1016
1073
  fiber.effect(() =>
1017
1074
  webServer.register({
1018
1075
  kind: 'exact',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Edit any user turn in DeepSeek Harness and re-run it: an inline editor on the message row rolls the conversation back to just before that turn through the official surface-replace contract, then re-prompts the agent with the revised text. The append-only session log is never rewritten.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",