dsh-edit-turn 0.5.7 → 0.5.9

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
@@ -277,6 +277,26 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
277
277
 
278
278
  ### 更新日志
279
279
 
280
+ **0.5.9** —— `/state` 瘦身:回复只发身份、全文按需取。
281
+
282
+ - **根因(两个症状同一个原因)**:`/state` 把每条回复的**整轮**推理与正文全文都发了出去。实测一个会话 291 条回复 = **27.9 MB**,一次 `/state` 要 **13 秒**。于是在它回来之前浏览器里没有任何编辑条目:笔图标迟迟不出现,**复制也就不会被本插件接管**(用户按复制时视图还是空的,按下落到宿主自己的处理上,复制出改写前的原文)。
283
+
284
+ - **修法**:`/state` 的 `replies` 只发身份 —— `seq` / `turn` / `messageId` / `attachments` / `textLength` / `blockCount`。全文与分块走新的 `GET /dsh-edit-turn/reply?sessionId=&messageId=`,只在真正需要时取一次(**按回复复制**、或打开编辑器),浏览器半部拿到后按 messageId 缓存。
285
+
286
+ - 宿主与客户端同步改:两个"打开回复编辑器"的入口与回复的复制按钮都改成"先取全文再动作",`currentTarget` 在 await 之前读出来(React 派发结束后它会变 null)。
287
+
288
+ - 测试改为断言新契约,并顺带覆盖新路由(`/state` 不再带 `blocks`,`/reply` 返回分块与原文)。
289
+
290
+ **0.5.8** —— 复制交给**平台写入器这一层**兜底:不管按下走哪条路,落到剪贴板的都是改写后的文本。
291
+
292
+ - **0.5.7 没修好**:那一版把"回答按下"从按钮节点挪到了文档级捕获监听(按行匹配,节点被重画也不影响),但实测仍然复制出原文。说明这次按下**根本没有经过本插件能拦到的按钮**——具体是哪个环节,从外部看不出来(没有可用的浏览器来读真实 DOM)。
293
+
294
+ - **所以换到更低的层**:`navigator.clipboard.writeText` 是**所有**复制都要经过的唯一关口——宿主自己的 `writeClipboard` 原语内部就是调它。本插件把这一层包起来:写入的文本如果正是某条被改写消息的**原文**(按行登记,空白归一化后比较),就换成改写后的文本;其余写入一字不动。
295
+
296
+ - **登记表放在页级**(`globalThis` 上的一个 Symbol),不是模块变量:实时重载会生成新的 bundle,而**旧 bundle 装的包装器还在写入路径上**(包装本身是幂等的)。两份 Map 会让包装器读到一个没人填的登记表。
297
+
298
+ - 新增测试:宿主**自己**写它那份原文(不经过本插件的按钮接管)时,落地的是改写后的文本;与改写无关的复制原样通过。测试台补了 `navigator.clipboard` 与 `childNodes`。
299
+
280
300
  **0.5.7** —— 改写过的行,复制按钮被宿主重画之后仍然交出改写后的文本。
281
301
 
282
302
  - **问题**:编辑过一条消息(提示词走官方 surface 替换),界面上显示的是改写后的文本,但点那一排的**复制**按钮,粘出来的是**改写前**的原文。
package/lib/client.js CHANGED
@@ -45,7 +45,7 @@ window.__ModuleLoader__.load({
45
45
  const RERUN_PREFIX = '/dsh-rerun-turn'
46
46
 
47
47
  /** Keep in sync with package.json and lib/index.js. */
48
- const PLUGIN_VERSION = '0.5.7'
48
+ const PLUGIN_VERSION = '0.5.9'
49
49
 
50
50
  // --- copy -----------------------------------------------------------------
51
51
 
@@ -141,6 +141,7 @@ window.__ModuleLoader__.load({
141
141
  'error.attachment-too-large': '这个文件太大,无法作为附件。',
142
142
  'error.too-large': '这次修改太大,服务器没有接收。',
143
143
  'error.replay-partial': '已保存并改写了这条回答,但后面的轮次没能全部重放回来,请重新打开会话确认。',
144
+ 'error.reply-not-found': '这条回答已经不可编辑了(可能已被改写或不在当前上下文里)。',
144
145
  }
145
146
 
146
147
  const en = {
@@ -230,6 +231,7 @@ window.__ModuleLoader__.load({
230
231
  'error.attachment-too-large': 'That file is too large to attach.',
231
232
  'error.too-large': 'That revision was too large for the host to accept.',
232
233
  'error.replay-partial': 'Saved and corrected, but the turns after it could not all be replayed; reopen the session to check.',
234
+ 'error.reply-not-found': 'That reply is no longer editable (it may have been rewritten, or left the current context).',
233
235
  }
234
236
 
235
237
  // Every error code the host can return travels through here. A code is
@@ -645,6 +647,9 @@ window.__ModuleLoader__.load({
645
647
  this.sessionId = sessionId
646
648
  this.listeners = new Set()
647
649
  this.inflight = null
650
+ // One reply's full wording, fetched on demand and kept: `/state` ships
651
+ // identities only (a reply carries its whole turn's text).
652
+ this.replyBodies = new Map()
648
653
  this.animateOnce = false
649
654
  this.rowCount = -1
650
655
  this.editor = null
@@ -749,6 +754,32 @@ window.__ModuleLoader__.load({
749
754
  return value
750
755
  }
751
756
 
757
+ /**
758
+ * One reply's full wording and blocks, on demand.
759
+ *
760
+ * `/state` publishes identities only: a reply carries its whole turn's
761
+ * reasoning and answer text, and a long session's list ran to 27.9 MB -
762
+ * 13 seconds for one refresh, which is why the edit entry appeared so late.
763
+ *
764
+ * @param {string} messageId - the reply's message id.
765
+ * @returns {Promise<object|null>} `{ text, blocks, attachments }`, or null.
766
+ */
767
+ replyBody(messageId) {
768
+ if (typeof messageId !== 'string' || messageId === '') return Promise.resolve(null)
769
+ const cached = this.replyBodies.get(messageId)
770
+ if (cached !== undefined) return cached
771
+ const url = `${ROUTE_PREFIX}/reply?sessionId=${encodeURIComponent(this.sessionId)}&messageId=${encodeURIComponent(messageId)}`
772
+ const pending = fetch(url, { headers: { accept: 'application/json' } })
773
+ .then(async (res) => {
774
+ const data = await res.json().catch(() => ({}))
775
+ if (!res.ok || !data.ok) throw new Error(data && data.error ? String(data.error) : `HTTP ${res.status}`)
776
+ return { text: data.text, blocks: Array.isArray(data.blocks) ? data.blocks : [], attachments: data.attachments }
777
+ })
778
+ .catch(() => null)
779
+ this.replyBodies.set(messageId, pending)
780
+ return pending
781
+ }
782
+
752
783
  load(force) {
753
784
  // A refresh asked for while one is already running has to land *after*
754
785
  // it: handing back the in-flight response would answer with a snapshot
@@ -1937,6 +1968,10 @@ window.__ModuleLoader__.load({
1937
1968
  const COPY_TIMER = 'dshetCopyTimer'
1938
1969
  /** The host draws its own check for a second; this is the same second. */
1939
1970
  const COPY_FEEDBACK_MS = 1000
1971
+ /** 标记:这一页的剪贴板写入已经被本半部包过一次(重载会复用同一个 page)。 */
1972
+ const CLIPBOARD_WRAPPED = Symbol.for('dshetClipboardWrapped')
1973
+ /** 页级登记表:重载后新 bundle 与旧包装器共用同一份。 */
1974
+ const COPY_REGISTRY = Symbol.for('dshetCopyRegistry')
1940
1975
  /**
1941
1976
  * Row key -> the text that row's copy button must hand over.
1942
1977
  *
@@ -1947,7 +1982,25 @@ window.__ModuleLoader__.load({
1947
1982
  * the button it drew goes away with that node (the button it draws next is a
1948
1983
  * different one).
1949
1984
  */
1950
- const rowCopyText = new Map()
1985
+ /**
1986
+ * Row key -> the text that row's copy must hand over, and the wording the host
1987
+ * would hand over instead.
1988
+ *
1989
+ * Both live on the PAGE rather than in this module: a live reload builds a new
1990
+ * bundle with new maps, and the clipboard wrapper the previous bundle installed
1991
+ * is still the one in the write path (wrapping is idempotent, by design). Two
1992
+ * maps would leave that wrapper reading a registry nobody fills.
1993
+ */
1994
+ const copyRegistry = (() => {
1995
+ const page = typeof globalThis !== 'undefined' ? globalThis : {}
1996
+ const existing = page[COPY_REGISTRY]
1997
+ if (existing !== undefined && existing !== null && existing.text instanceof Map) return existing
1998
+ const created = { text: new Map(), host: new Map() }
1999
+ page[COPY_REGISTRY] = created
2000
+ return created
2001
+ })()
2002
+ const rowCopyText = copyRegistry.text
2003
+ const rowHostText = copyRegistry.host
1951
2004
 
1952
2005
  /**
1953
2006
  * A button another plugin put in this bar.
@@ -2011,13 +2064,93 @@ window.__ModuleLoader__.load({
2011
2064
  * host's own clipboard writer the button keeps the host's behaviour too: a
2012
2065
  * takeover that cannot copy is worse than none.
2013
2066
  */
2067
+ /**
2068
+ * The wording the host's own copy button would hand over for this row.
2069
+ *
2070
+ * It is the message the row was drawn for - the pre-edit text - and it is read
2071
+ * off the row itself, because that is where it still is: this plugin hides the
2072
+ * row's own bubble and stands its revision in front of it.
2073
+ *
2074
+ * @param {Element} row - the row.
2075
+ * @returns {string|null} the host's text, or null when it cannot be read.
2076
+ */
2077
+ function hostCopyTextOf(row) {
2078
+ try {
2079
+ const bubble = ownRevisionBubble(row)
2080
+ let text = ''
2081
+ const children = row.childNodes
2082
+ if (children === undefined || children === null) return null
2083
+ for (const child of children) {
2084
+ if (bubble !== null && child === bubble) continue
2085
+ if (typeof child.textContent === 'string') text += child.textContent
2086
+ }
2087
+ const normalized = text.replace(/\s+/g, ' ').trim()
2088
+ return normalized === '' ? null : normalized
2089
+ } catch {
2090
+ return null
2091
+ }
2092
+ }
2093
+
2094
+ /**
2095
+ * The wording that must actually land on the clipboard.
2096
+ *
2097
+ * The host's copy button copies the message its row was drawn for, so on a row
2098
+ * this plugin rewrote it hands over the pre-edit text. The press itself is
2099
+ * answered earlier when this half can find the button, but the action bar is the
2100
+ * host's own React and it may draw a button this half never marked - so the last
2101
+ * say belongs to the write itself, which no button and no handler can bypass.
2102
+ *
2103
+ * @param {string} text - what the platform is about to write.
2104
+ * @returns {string|null} the text to write instead, or null to leave it alone.
2105
+ */
2106
+ function rewriteCopiedText(text) {
2107
+ const wanted = text.replace(/\s+/g, ' ').trim()
2108
+ if (wanted === '') return null
2109
+ for (const [key, edited] of rowCopyText) {
2110
+ if (typeof edited !== 'string') continue
2111
+ if (edited.replace(/\s+/g, ' ').trim() === wanted) return null
2112
+ const hostText = rowHostText.get(key)
2113
+ if (typeof hostText === 'string' && (hostText === wanted || hostText.includes(wanted))) return edited
2114
+ }
2115
+ return null
2116
+ }
2117
+
2118
+ /**
2119
+ * Wrap the platform's clipboard writer, once per page.
2120
+ *
2121
+ * `navigator.clipboard.writeText` is the single choke point every copy goes
2122
+ * through - the host's own `writeClipboard` primitive calls it, and so does a
2123
+ * browser's native copy of a selection.
2124
+ *
2125
+ * @returns {() => void} undo.
2126
+ */
2127
+ function installClipboardRewrite() {
2128
+ const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined
2129
+ if (clipboard === undefined || clipboard === null || typeof clipboard.writeText !== 'function') return () => {}
2130
+ if (clipboard.writeText[CLIPBOARD_WRAPPED] === true) return () => {}
2131
+ const native = clipboard.writeText.bind(clipboard)
2132
+ const wrapped = (value) => {
2133
+ const rewritten = rewriteCopiedText(String(value ?? ''))
2134
+ return rewritten === null ? native(value) : native(rewritten)
2135
+ }
2136
+ wrapped[CLIPBOARD_WRAPPED] = true
2137
+ clipboard.writeText = wrapped
2138
+ return () => {
2139
+ if (clipboard.writeText === wrapped) clipboard.writeText = native
2140
+ }
2141
+ }
2142
+
2014
2143
  function takeOverRowCopy(row, text, t) {
2015
2144
  // Registered before the button lookup on purpose: the handover has to work
2016
2145
  // even when this pass cannot find the button to mark (the next one may, and
2017
2146
  // the document-level press answers from this table either way).
2018
2147
  {
2019
2148
  const key = row.getAttribute('data-chat-flow-key')
2020
- if (typeof key === 'string' && key !== '') rowCopyText.set(key, text)
2149
+ if (typeof key === 'string' && key !== '') {
2150
+ rowCopyText.set(key, text)
2151
+ const hostText = hostCopyTextOf(row)
2152
+ if (hostText !== null) rowHostText.set(key, hostText)
2153
+ }
2021
2154
  }
2022
2155
  const button = nativeCopyButton(row, t)
2023
2156
  if (button === null) return
@@ -2148,7 +2281,10 @@ window.__ModuleLoader__.load({
2148
2281
  /** The copy handover of a row, wherever the last pass left it. */
2149
2282
  function releaseRowCopy(row) {
2150
2283
  const key = row.getAttribute('data-chat-flow-key')
2151
- if (typeof key === 'string' && key !== '') rowCopyText.delete(key)
2284
+ if (typeof key === 'string' && key !== '') {
2285
+ rowCopyText.delete(key)
2286
+ rowHostText.delete(key)
2287
+ }
2152
2288
  const button = nativeCopyButton(row)
2153
2289
  if (button !== null) releaseCopyButton(button)
2154
2290
  }
@@ -4722,37 +4858,55 @@ window.__ModuleLoader__.load({
4722
4858
  const open = (event) => {
4723
4859
  event.preventDefault()
4724
4860
  event.stopPropagation()
4725
- controller.open({
4726
- anchor: event.currentTarget,
4727
- seq: entry.seq,
4728
- mode: 'reply',
4729
- turn: entry.turn,
4730
- messageId: entry.messageId,
4731
- text: typeof entry.text === 'string' ? entry.text : '',
4732
- attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
4733
- ...(Array.isArray(entry.blocks) ? { blocks: entry.blocks, patch: true } : { blocks: [] }),
4734
- })
4861
+ // `currentTarget` is null once this handler returns, so it is read before
4862
+ // the await that may fetch the reply's wording.
4863
+ const anchor = event.currentTarget
4864
+ void (async () => {
4865
+ let body = null
4866
+ if (!Array.isArray(entry.blocks) && typeof entry.blockCount === 'number' && entry.blockCount > 0) {
4867
+ body = await controller.replyBody(entry.messageId)
4868
+ }
4869
+ const blocks = body !== null && Array.isArray(body.blocks)
4870
+ ? body.blocks
4871
+ : Array.isArray(entry.blocks) ? entry.blocks : []
4872
+ controller.open({
4873
+ anchor,
4874
+ seq: entry.seq,
4875
+ mode: 'reply',
4876
+ turn: entry.turn,
4877
+ messageId: entry.messageId,
4878
+ text: body !== null && typeof body.text === 'string'
4879
+ ? body.text
4880
+ : typeof entry.text === 'string' ? entry.text : '',
4881
+ attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
4882
+ ...(blocks.length > 0 ? { blocks, patch: true } : { blocks: [] }),
4883
+ })
4884
+ })()
4735
4885
  }
4736
4886
  const copy = (event) => {
4737
4887
  event.preventDefault()
4738
4888
  event.stopPropagation()
4739
- const text = replyCopyText(entry)
4740
- if (text === '') return
4741
- const write = primitives !== null && primitives !== undefined && typeof primitives.writeClipboard === 'function'
4742
- ? primitives.writeClipboard
4743
- : typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function'
4744
- ? (value) => navigator.clipboard.writeText(value)
4745
- : null
4746
- if (write === null) return
4747
- Promise.resolve()
4748
- .then(() => write(text))
4749
- .then(
4750
- () => {
4751
- setCopied(true)
4752
- window.setTimeout(() => setCopied(false), 1200)
4753
- },
4754
- () => {},
4755
- )
4889
+ void (async () => {
4890
+ let text = replyCopyText(entry)
4891
+ if (text === '' && typeof entry.blockCount === 'number' && entry.blockCount > 0) {
4892
+ const body = await controller.replyBody(entry.messageId)
4893
+ if (body !== null) text = replyCopyText(body)
4894
+ }
4895
+ if (text === '') return
4896
+ const write = primitives !== null && primitives !== undefined && typeof primitives.writeClipboard === 'function'
4897
+ ? primitives.writeClipboard
4898
+ : typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function'
4899
+ ? (value) => navigator.clipboard.writeText(value)
4900
+ : null
4901
+ if (write === null) return
4902
+ try {
4903
+ await write(text)
4904
+ } catch {
4905
+ return
4906
+ }
4907
+ setCopied(true)
4908
+ window.setTimeout(() => setCopied(false), 1200)
4909
+ })()
4756
4910
  }
4757
4911
  return jsxs('div', {
4758
4912
  className: 'dshet-tail',
@@ -4793,6 +4947,10 @@ window.__ModuleLoader__.load({
4793
4947
  controller.load()
4794
4948
  }, [controller])
4795
4949
 
4950
+ // The last say on what lands: whatever route the press takes, the write
4951
+ // itself goes through here.
4952
+ react.useEffect(() => installClipboardRewrite(), [])
4953
+
4796
4954
  // Pressing outside the editor is the one signal that means "the user
4797
4955
  // left". Blur cannot be that signal: a host re-render replaces the node
4798
4956
  // (blur without a related target) and a save temporarily disables it -
package/lib/index.js CHANGED
@@ -78,7 +78,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
78
78
  export const name = PLUGIN_ID
79
79
 
80
80
  /** Keep in sync with package.json and lib/client.js. */
81
- export const PLUGIN_VERSION = '0.5.7'
81
+ export const PLUGIN_VERSION = '0.5.9'
82
82
 
83
83
  // A ring of the last few requests, answering "did the browser half even ask, and
84
84
  // what did it get?" without needing its console. Read-only diagnostics: the
@@ -1686,6 +1686,50 @@ export async function attachmentBytes(ctx, sessionId, seq, index) {
1686
1686
 
1687
1687
  // --- operations --------------------------------------------------------------
1688
1688
 
1689
+ /**
1690
+ * A reply's identity, without its words.
1691
+ *
1692
+ * One reply carries every reasoning and answer block of its **turn**, so a long
1693
+ * session's reply list is tens of megabytes: measured 291 replies = 27.9 MB, and
1694
+ * a single `/state` took 13 seconds - long enough that the browser showed no edit
1695
+ * entry, and slow enough to look like the plugin had not loaded at all. The words
1696
+ * are fetched when the user actually wants them (`/reply`).
1697
+ *
1698
+ * @param {object} entry - one entry from `editableReplies`.
1699
+ * @returns {object} the identity-only projection.
1700
+ */
1701
+ export function lightReply(entry) {
1702
+ return {
1703
+ seq: entry.seq,
1704
+ turn: entry.turn,
1705
+ messageId: entry.messageId,
1706
+ attachments: entry.attachments,
1707
+ textLength: typeof entry.text === 'string' ? entry.text.length : 0,
1708
+ blockCount: Array.isArray(entry.blocks) ? entry.blocks.length : 0,
1709
+ }
1710
+ }
1711
+
1712
+ /**
1713
+ * One reply's full wording and blocks, for a copy press or an opened editor.
1714
+ *
1715
+ * @param ctx - plugin context.
1716
+ * @param {string} sessionId - the session.
1717
+ * @param {object} config - resolved config.
1718
+ * @param {string} messageId - the reply's message id.
1719
+ * @returns {Promise<object|null>} `{ text, blocks, attachments }`, or null.
1720
+ */
1721
+ async function replyOf(ctx, sessionId, config, messageId) {
1722
+ const events = await readEvents(ctx, sessionId)
1723
+ if (!events) return null
1724
+ const folded = foldSurface(events)
1725
+ const entry = editableReplies(events, folded.nodes, {
1726
+ patches: patchesOf(events),
1727
+ patch: config.patch === true,
1728
+ }).find((candidate) => candidate.messageId === messageId)
1729
+ if (entry === undefined) return null
1730
+ return { text: entry.text, blocks: entry.blocks, attachments: entry.attachments }
1731
+ }
1732
+
1689
1733
  async function stateOf(ctx, sessionId, config) {
1690
1734
  const events = await readEvents(ctx, sessionId)
1691
1735
  if (!events) {
@@ -1734,7 +1778,7 @@ async function stateOf(ctx, sessionId, config) {
1734
1778
  // editor promising to keep blocks the host would have to drop.
1735
1779
  const store = resolveAttachmentStore(ctx)
1736
1780
  const turns = editableTurns(events, folded.nodes, { preview: canPreviewAttachments(store) })
1737
- const replies = editableReplies(events, folded.nodes, { patches, patch: config.patch === true })
1781
+ const replies = editableReplies(events, folded.nodes, { patches, patch: config.patch === true }).map(lightReply)
1738
1782
  noteRequest({
1739
1783
  kind: 'state',
1740
1784
  sessionId,
@@ -2587,6 +2631,33 @@ export function apply(ctx, config) {
2587
2631
  }),
2588
2632
  )
2589
2633
 
2634
+ // One reply's wording and blocks, on demand: `/state` publishes identities
2635
+ // only, because a reply can carry a whole turn's worth of text.
2636
+ fiber.effect(() =>
2637
+ webServer.register({
2638
+ kind: 'exact',
2639
+ path: `${ROUTE_PREFIX}/reply`,
2640
+ handler: async (req, res) => {
2641
+ if (!guard(req, res)) return
2642
+ if (req.method !== 'GET') {
2643
+ sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
2644
+ return
2645
+ }
2646
+ try {
2647
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
2648
+ const sessionId = requireSessionId(url.searchParams.get('sessionId') ?? '')
2649
+ const messageId = String(url.searchParams.get('messageId') ?? '').trim()
2650
+ if (messageId === '') throw new HttpError(400, 'invalid', 'messageId required')
2651
+ const body = await replyOf(ctx, sessionId, resolved, messageId)
2652
+ if (body === null) throw new HttpError(404, 'reply-not-found', 'this reply is not editable')
2653
+ sendJson(res, 200, { ok: true, messageId, ...body })
2654
+ } catch (error) {
2655
+ failure(res, error)
2656
+ }
2657
+ },
2658
+ }),
2659
+ )
2660
+
2590
2661
  // The bytes behind one block, for the editor's chips. Read-only, loopback
2591
2662
  // and same-origin like everything else here, and answered only for a block
2592
2663
  // the session's own log cites.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "Edit any message in DeepSeek Harness in place: a prompt is replaced where it stands; a model reply block (answer text or reasoning) is patched through a plugin record and a message projection, so the turn stays where it is, later turns are untouched and the log grows by one small record. Falls back to the replay splice on hosts without the projection API. The append-only session log is never rewritten.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",