dsh-edit-turn 0.5.8 → 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 +10 -0
- package/lib/client.js +77 -28
- package/lib/index.js +73 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -277,6 +277,16 @@ 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
|
+
|
|
280
290
|
**0.5.8** —— 复制交给**平台写入器这一层**兜底:不管按下走哪条路,落到剪贴板的都是改写后的文本。
|
|
281
291
|
|
|
282
292
|
- **0.5.7 没修好**:那一版把"回答按下"从按钮节点挪到了文档级捕获监听(按行匹配,节点被重画也不影响),但实测仍然复制出原文。说明这次按下**根本没有经过本插件能拦到的按钮**——具体是哪个环节,从外部看不出来(没有可用的浏览器来读真实 DOM)。
|
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.
|
|
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
|
|
@@ -4827,37 +4858,55 @@ window.__ModuleLoader__.load({
|
|
|
4827
4858
|
const open = (event) => {
|
|
4828
4859
|
event.preventDefault()
|
|
4829
4860
|
event.stopPropagation()
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
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
|
+
})()
|
|
4840
4885
|
}
|
|
4841
4886
|
const copy = (event) => {
|
|
4842
4887
|
event.preventDefault()
|
|
4843
4888
|
event.stopPropagation()
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
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
|
+
})()
|
|
4861
4910
|
}
|
|
4862
4911
|
return jsxs('div', {
|
|
4863
4912
|
className: 'dshet-tail',
|
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.
|
|
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.
|
|
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",
|