dsh-edit-turn 0.1.0
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/LICENSE +21 -0
- package/README.md +342 -0
- package/cordis.patch.yml +14 -0
- package/lib/client.js +773 -0
- package/lib/index.js +903 -0
- package/package.json +73 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
// dsh-edit-turn - browser half.
|
|
2
|
+
//
|
|
3
|
+
// One entry point: a per-session controller mounted through the official
|
|
4
|
+
// `conversation.input.overlay` list slot. It renders the in-place editor and
|
|
5
|
+
// enhances the message rows the host UI exposes no action slot for - user
|
|
6
|
+
// messages have no official action slot, which is exactly why the edit entry
|
|
7
|
+
// lives on the DOM row.
|
|
8
|
+
//
|
|
9
|
+
// Row targeting reads the official `useChat` standard hook (the ChatSnapshot
|
|
10
|
+
// keyed by the same `data-chat-flow-key` the DOM publishes) plus the official
|
|
11
|
+
// `data-chat-flow-*` anchors. No React fiber introspection and no CSS-module
|
|
12
|
+
// class hashing are involved, so a host UI refactor cannot silently detach the
|
|
13
|
+
// action.
|
|
14
|
+
//
|
|
15
|
+
// The module is a classic client bundle (client-modules protocol): it registers
|
|
16
|
+
// a factory with window.__ModuleLoader__ and returns apply().
|
|
17
|
+
window.__ModuleLoader__.load({
|
|
18
|
+
id: 'dsh-edit-turn',
|
|
19
|
+
factory: (require) => {
|
|
20
|
+
var module = { exports: {} }
|
|
21
|
+
var exports = module.exports
|
|
22
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
23
|
+
|
|
24
|
+
const react = require('react')
|
|
25
|
+
const jsxRuntime = require('react/jsx-runtime')
|
|
26
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
27
|
+
const { jsx, jsxs, Fragment } = jsxRuntime
|
|
28
|
+
|
|
29
|
+
const NS = 'dsh-edit-turn'
|
|
30
|
+
const ROUTE_PREFIX = '/dsh-edit-turn'
|
|
31
|
+
|
|
32
|
+
/** Keep in sync with package.json and lib/index.js. */
|
|
33
|
+
const PLUGIN_VERSION = '0.1.0'
|
|
34
|
+
|
|
35
|
+
// --- copy -----------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const zh = {
|
|
38
|
+
'action.edit': '编辑这条消息并重跑',
|
|
39
|
+
'editor.title': '编辑这条消息',
|
|
40
|
+
'editor.placeholder': '修改后确认,会话将回退到这条消息之前并重新跑这一轮。',
|
|
41
|
+
'editor.save': '保存并重跑',
|
|
42
|
+
'editor.review': '确认回退',
|
|
43
|
+
'editor.cancel': '取消',
|
|
44
|
+
'editor.pending': '正在回退并重跑…',
|
|
45
|
+
'editor.warn.attachments': '这条消息包含图片或文件附件,改写会丢弃它们,只保留文字。',
|
|
46
|
+
'editor.hint': '保存后,这条消息之后的全部内容(含当轮回复与工具调用)会从模型上下文中移除,并从这里重新开始。原始会话日志不会改写。',
|
|
47
|
+
'confirm.title': '确认回退并重跑?',
|
|
48
|
+
'confirm.body': '这条消息之后的全部内容会从模型上下文中移除,然后用你改写后的内容重新跑这一轮。原始会话日志保持不变。',
|
|
49
|
+
'error.invalid': '请求无效,请刷新后重试。',
|
|
50
|
+
'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
|
|
51
|
+
'error.session-not-found': '找不到该会话的日志。',
|
|
52
|
+
'error.busy': '该会话正在回复中,请等回复结束后再编辑。',
|
|
53
|
+
'error.not-editable': '这条消息不支持编辑。',
|
|
54
|
+
'error.already-rolled-back': '这条消息已经不在当前上下文里了。',
|
|
55
|
+
'error.stale': '会话刚刚发生了变化,请重试。',
|
|
56
|
+
'error.forbidden': '请求来源不被允许。',
|
|
57
|
+
'error.method': '请求方式不被接受,请刷新后重试。',
|
|
58
|
+
'error.generic': '编辑失败,请重试。',
|
|
59
|
+
'error.prompt': '已回退,但重跑没有启动。请手动发送改写后的内容。',
|
|
60
|
+
'error.empty': '改写后的内容不能为空。',
|
|
61
|
+
'error.internal': '服务器内部错误,请稍后重试。',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const en = {
|
|
65
|
+
'action.edit': 'Edit this message and re-run',
|
|
66
|
+
'editor.title': 'Edit this message',
|
|
67
|
+
'editor.placeholder': 'Confirm after editing: the conversation rolls back to just before this message and this turn runs again.',
|
|
68
|
+
'editor.save': 'Save and re-run',
|
|
69
|
+
'editor.review': 'Confirm rollback',
|
|
70
|
+
'editor.cancel': 'Cancel',
|
|
71
|
+
'editor.pending': 'Rolling back and re-running...',
|
|
72
|
+
'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
|
|
73
|
+
'editor.hint': 'Saving removes everything after this message (including that turn\'s reply and tool calls) from the model context and starts again from here. The original session log is never rewritten.',
|
|
74
|
+
'confirm.title': 'Roll back and re-run?',
|
|
75
|
+
'confirm.body': 'Everything after this message leaves the model context, then this turn runs again from your revised text. The original session log stays untouched.',
|
|
76
|
+
'error.invalid': 'Invalid request; refresh and try again.',
|
|
77
|
+
'error.session-not-active': 'This session is not open in DSH; open it first.',
|
|
78
|
+
'error.session-not-found': 'No session log was found for this id.',
|
|
79
|
+
'error.busy': 'This session is still replying; wait for it to finish.',
|
|
80
|
+
'error.not-editable': 'This message cannot be edited.',
|
|
81
|
+
'error.already-rolled-back': 'This message is no longer in the current context.',
|
|
82
|
+
'error.stale': 'The session just changed; try again.',
|
|
83
|
+
'error.forbidden': 'The request origin is not allowed.',
|
|
84
|
+
'error.method': 'The request method is not accepted; refresh and try again.',
|
|
85
|
+
'error.generic': 'The edit failed; try again.',
|
|
86
|
+
'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
|
|
87
|
+
'error.empty': 'The revised message cannot be empty.',
|
|
88
|
+
'error.internal': 'The host hit an internal error; try again shortly.',
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Every error code the host can return travels through here. A code is
|
|
92
|
+
// validated against the dictionary before it becomes a lookup key, so a
|
|
93
|
+
// missing or unknown code can never reach `t()` as a constructed key - the
|
|
94
|
+
// locale would return the key itself and the UI would print
|
|
95
|
+
// "error.undefined" at the user.
|
|
96
|
+
const ERROR_KEYS = new Set(Object.keys(en).filter((key) => key.startsWith('error.')))
|
|
97
|
+
function errorText(t, code) {
|
|
98
|
+
return t(typeof code === 'string' && ERROR_KEYS.has(`error.${code}`) ? `error.${code}` : 'error.generic')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- style ----------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
const CSS = [
|
|
104
|
+
'.dshet-action{width:28px;height:28px;padding:6px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary,#8a8f98);cursor:pointer;transition:background-color .12s,color .12s}',
|
|
105
|
+
'.dshet-action:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,inherit)}',
|
|
106
|
+
'.dshet-action:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4d6bfe);outline-offset:2px}',
|
|
107
|
+
'.dshet-action:disabled{cursor:default;opacity:.4}',
|
|
108
|
+
'.dshet-action svg{width:15px;height:15px}',
|
|
109
|
+
'.dshet-action-host{display:inline-flex;align-items:center;justify-content:center}',
|
|
110
|
+
'.dshet-row{position:relative}',
|
|
111
|
+
'.dshet-floating{position:absolute;top:2px;right:6px;z-index:2;opacity:0;transition:opacity .12s}',
|
|
112
|
+
'.dshet-row:hover .dshet-floating,.dshet-floating:focus-within{opacity:1}',
|
|
113
|
+
'.dshet-collapsing{overflow:hidden;transition:height .2s ease,opacity .14s ease,margin .2s ease,padding .2s ease}',
|
|
114
|
+
'[data-dshet-hidden="1"]{display:none!important}',
|
|
115
|
+
'.dshet-editor{margin:8px 0 4px;padding:12px;border:1px solid var(--dsw-alias-border-secondary,rgba(127,127,127,.28));border-radius:12px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 2px 10px rgba(0,0,0,.06)}',
|
|
116
|
+
'.dshet-editor-title{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,inherit)}',
|
|
117
|
+
'.dshet-editor textarea{width:100%;min-height:84px;max-height:46vh;resize:vertical;box-sizing:border-box;padding:10px 12px;border:1px solid var(--dsw-alias-border-secondary,rgba(127,127,127,.32));border-radius:8px;background:var(--dsw-alias-bg-base,transparent);color:var(--dsw-alias-label-primary,inherit);font:inherit;font-size:14px;line-height:22px}',
|
|
118
|
+
'.dshet-editor textarea:focus{outline:none;border-color:var(--dsw-alias-button-primary-fill,#4d6bfe)}',
|
|
119
|
+
'.dshet-editor textarea:disabled{opacity:.6}',
|
|
120
|
+
'.dshet-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:19px}',
|
|
121
|
+
'.dshet-warn{margin:8px 0 0;color:var(--dsw-alias-state-warning-primary,#c47b12);font-size:12px;line-height:19px}',
|
|
122
|
+
'.dshet-error{margin:8px 0 0;color:var(--dsw-alias-state-error-primary,#d54941);font-size:12px;line-height:19px}',
|
|
123
|
+
'.dshet-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:12px}',
|
|
124
|
+
'.dshet-btn{padding:6px 14px;border:1px solid var(--dsw-alias-border-secondary,rgba(127,127,127,.32));border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary,inherit);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background-color .12s,border-color .12s}',
|
|
125
|
+
'.dshet-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}',
|
|
126
|
+
'.dshet-btn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4d6bfe);outline-offset:2px}',
|
|
127
|
+
'.dshet-btn:disabled{cursor:default;opacity:.5}',
|
|
128
|
+
'.dshet-btn-primary{border-color:transparent;background:var(--dsw-alias-button-primary-fill,#4d6bfe);color:var(--dsw-alias-label-primary-foreground,#fff)}',
|
|
129
|
+
'.dshet-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-fill,#4d6bfe);filter:brightness(1.08)}',
|
|
130
|
+
'.dshet-notice{margin:4px 0;padding:6px 10px;border-radius:8px;background:var(--dsw-alias-bg-layer-1,rgba(127,127,127,.08));color:var(--dsw-alias-state-error-primary,#d54941);font-size:12px;line-height:19px}',
|
|
131
|
+
'.dshet-spacer{flex:1 1 auto}',
|
|
132
|
+
'@media (prefers-reduced-motion:reduce){.dshet-collapsing{transition:none}.dshet-floating{transition:none}}',
|
|
133
|
+
].join('')
|
|
134
|
+
|
|
135
|
+
const TAG_ID = 'dsh-edit-turn/edit-turn.css'
|
|
136
|
+
if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(TAG_ID) + ']') === null) {
|
|
137
|
+
const tag = document.createElement('style')
|
|
138
|
+
tag.dataset.plugin = NS
|
|
139
|
+
tag.dataset.pluginCss = TAG_ID
|
|
140
|
+
tag.textContent = CSS
|
|
141
|
+
document.head.appendChild(tag)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- icon -----------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
// Hand-drawn pencil glyph: body, tip, baseline.
|
|
147
|
+
const ICON_PATHS = [
|
|
148
|
+
'M11.2 2.4l2.4 2.4',
|
|
149
|
+
'M3.1 10.5l7.1-7.1 2.4 2.4-7.1 7.1-3.1.7z',
|
|
150
|
+
'M2.6 13.6h10.8',
|
|
151
|
+
]
|
|
152
|
+
const ICON_MARKUP =
|
|
153
|
+
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
|
|
154
|
+
ICON_PATHS.map(
|
|
155
|
+
(d) =>
|
|
156
|
+
'<path d="' + d + '" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
157
|
+
).join('') +
|
|
158
|
+
'</svg>'
|
|
159
|
+
|
|
160
|
+
function PencilIcon() {
|
|
161
|
+
return jsx('svg', {
|
|
162
|
+
width: 16,
|
|
163
|
+
height: 16,
|
|
164
|
+
viewBox: '0 0 16 16',
|
|
165
|
+
fill: 'none',
|
|
166
|
+
'aria-hidden': true,
|
|
167
|
+
children: ICON_PATHS.map((d, index) =>
|
|
168
|
+
jsx('path', { d, stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round' }, index),
|
|
169
|
+
),
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- controller -----------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
class EditController {
|
|
176
|
+
constructor(sessionId) {
|
|
177
|
+
this.sessionId = sessionId
|
|
178
|
+
this.listeners = new Set()
|
|
179
|
+
this.inflight = null
|
|
180
|
+
this.animateOnce = false
|
|
181
|
+
this.rowCount = -1
|
|
182
|
+
this.editor = null
|
|
183
|
+
this.editorElement = null
|
|
184
|
+
this.editorDraft = ''
|
|
185
|
+
this.confirming = false
|
|
186
|
+
this.noticeTimer = null
|
|
187
|
+
this.view = Object.freeze({
|
|
188
|
+
hidden: new Map(),
|
|
189
|
+
editable: new Map(),
|
|
190
|
+
editing: null,
|
|
191
|
+
draft: '',
|
|
192
|
+
confirming: false,
|
|
193
|
+
pending: false,
|
|
194
|
+
failure: null,
|
|
195
|
+
notice: null,
|
|
196
|
+
surfaceReady: false,
|
|
197
|
+
loaded: false,
|
|
198
|
+
loadError: false,
|
|
199
|
+
confirmStep: true,
|
|
200
|
+
revision: 0,
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
getSnapshot = () => this.view
|
|
205
|
+
|
|
206
|
+
subscribe = (listener) => {
|
|
207
|
+
this.listeners.add(listener)
|
|
208
|
+
return () => {
|
|
209
|
+
this.listeners.delete(listener)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
publish(patch) {
|
|
214
|
+
this.view = Object.freeze({ ...this.view, ...patch, revision: this.view.revision + 1 })
|
|
215
|
+
for (const listener of this.listeners) {
|
|
216
|
+
try {
|
|
217
|
+
listener()
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.error('[dsh-edit-turn] subscriber threw:', error)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
consumeAnimate() {
|
|
225
|
+
const value = this.animateOnce === true
|
|
226
|
+
this.animateOnce = false
|
|
227
|
+
return value
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
load(force) {
|
|
231
|
+
if (this.inflight !== null) return this.inflight
|
|
232
|
+
if (this.view.loaded && force !== true) return Promise.resolve()
|
|
233
|
+
const url = `${ROUTE_PREFIX}/state?sessionId=${encodeURIComponent(this.sessionId)}`
|
|
234
|
+
const pending = fetch(url, { headers: { accept: 'application/json' } })
|
|
235
|
+
.then(async (res) => {
|
|
236
|
+
const data = await res.json().catch(() => ({}))
|
|
237
|
+
if (!res.ok || !data.ok) throw new Error(data && data.error ? String(data.error) : `HTTP ${res.status}`)
|
|
238
|
+
const hidden = new Map()
|
|
239
|
+
for (const item of Array.isArray(data.hidden) ? data.hidden : []) {
|
|
240
|
+
if (item && typeof item.seq === 'number') hidden.set(item.seq, item.turn)
|
|
241
|
+
}
|
|
242
|
+
const editable = new Map()
|
|
243
|
+
for (const turn of Array.isArray(data.turns) ? data.turns : []) {
|
|
244
|
+
if (turn && typeof turn.seq === 'number') editable.set(turn.seq, turn)
|
|
245
|
+
}
|
|
246
|
+
this.publish({
|
|
247
|
+
hidden,
|
|
248
|
+
editable,
|
|
249
|
+
surfaceReady: true,
|
|
250
|
+
loaded: true,
|
|
251
|
+
loadError: false,
|
|
252
|
+
confirmStep: !(data.config && data.config.confirm === false),
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
.catch(() => {
|
|
256
|
+
this.publish({ loadError: true })
|
|
257
|
+
})
|
|
258
|
+
.finally(() => {
|
|
259
|
+
this.inflight = null
|
|
260
|
+
})
|
|
261
|
+
this.inflight = pending
|
|
262
|
+
return pending
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
open(target) {
|
|
266
|
+
this.load()
|
|
267
|
+
this.editor = target
|
|
268
|
+
this.editorDraft = target.text
|
|
269
|
+
this.confirming = false
|
|
270
|
+
this.editorElement = null
|
|
271
|
+
this.publish({ editing: target, draft: target.text, confirming: false, failure: null })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
close() {
|
|
275
|
+
if (this.view.pending) return
|
|
276
|
+
this.editor = null
|
|
277
|
+
this.editorDraft = ''
|
|
278
|
+
this.confirming = false
|
|
279
|
+
this.editorElement = null
|
|
280
|
+
this.publish({ editing: null, draft: '', confirming: false, failure: null })
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
setDraft(value) {
|
|
284
|
+
this.editorDraft = value
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
review() {
|
|
288
|
+
if (this.editorDraft.trim() === '') {
|
|
289
|
+
this.publish({ failure: 'empty' })
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
if (this.view.confirmStep === false) {
|
|
293
|
+
this.confirm()
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
this.confirming = true
|
|
297
|
+
this.publish({ confirming: true, draft: this.editorDraft, failure: null })
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
back() {
|
|
301
|
+
if (this.view.pending) return
|
|
302
|
+
this.confirming = false
|
|
303
|
+
this.publish({ confirming: false, failure: null })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async confirm() {
|
|
307
|
+
const target = this.editor
|
|
308
|
+
if (target === null || this.view.pending) return
|
|
309
|
+
const text = this.editorDraft
|
|
310
|
+
if (text.trim() === '') {
|
|
311
|
+
this.publish({ failure: 'empty' })
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
this.publish({ pending: true, failure: null })
|
|
315
|
+
try {
|
|
316
|
+
const res = await fetch(`${ROUTE_PREFIX}/apply`, {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: { 'content-type': 'application/json' },
|
|
319
|
+
body: JSON.stringify({
|
|
320
|
+
sessionId: this.sessionId,
|
|
321
|
+
seq: target.seq,
|
|
322
|
+
messageId: typeof target.messageId === 'string' ? target.messageId : undefined,
|
|
323
|
+
text,
|
|
324
|
+
}),
|
|
325
|
+
})
|
|
326
|
+
const data = await res.json().catch(() => ({}))
|
|
327
|
+
if (!res.ok || !data.ok) {
|
|
328
|
+
this.publish({ pending: false, failure: data && data.code ? String(data.code) : 'generic' })
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
const hidden = new Map(this.view.hidden)
|
|
332
|
+
for (const seq of Array.isArray(data.shadowed) ? data.shadowed : []) hidden.set(seq, target.turn)
|
|
333
|
+
const editable = new Map(this.view.editable)
|
|
334
|
+
for (const entry of editable.values()) {
|
|
335
|
+
if (hidden.has(entry.seq)) editable.delete(entry.seq)
|
|
336
|
+
}
|
|
337
|
+
this.animateOnce = true
|
|
338
|
+
this.editor = null
|
|
339
|
+
this.editorDraft = ''
|
|
340
|
+
this.editorElement = null
|
|
341
|
+
this.confirming = false
|
|
342
|
+
this.publish({
|
|
343
|
+
pending: false,
|
|
344
|
+
editing: null,
|
|
345
|
+
confirming: false,
|
|
346
|
+
hidden,
|
|
347
|
+
editable,
|
|
348
|
+
failure: null,
|
|
349
|
+
})
|
|
350
|
+
// The rollback has landed, so the editor is gone with its row: a
|
|
351
|
+
// failed re-run has to be reported somewhere that survives that.
|
|
352
|
+
if (data.promptAccepted === false) this.notify('prompt')
|
|
353
|
+
} catch {
|
|
354
|
+
this.publish({ pending: false, failure: 'generic' })
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
notify(code) {
|
|
359
|
+
if (this.noticeTimer !== null) window.clearTimeout(this.noticeTimer)
|
|
360
|
+
this.publish({ notice: code })
|
|
361
|
+
this.noticeTimer = window.setTimeout(() => {
|
|
362
|
+
this.noticeTimer = null
|
|
363
|
+
this.publish({ notice: null })
|
|
364
|
+
}, 12_000)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
dismissNotice() {
|
|
368
|
+
if (this.noticeTimer !== null) window.clearTimeout(this.noticeTimer)
|
|
369
|
+
this.noticeTimer = null
|
|
370
|
+
this.publish({ notice: null })
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
dispose() {
|
|
374
|
+
this.listeners.clear()
|
|
375
|
+
if (this.noticeTimer !== null) window.clearTimeout(this.noticeTimer)
|
|
376
|
+
this.noticeTimer = null
|
|
377
|
+
this.editorElement = null
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// --- row targets ----------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
// The editable target of a Chat view node, or null when the row is not a
|
|
384
|
+
// human prompt the user can rewrite.
|
|
385
|
+
function targetFor(node, editable, hidden) {
|
|
386
|
+
if (!node || node.kind !== 'user') return null
|
|
387
|
+
const data = node.data || {}
|
|
388
|
+
const seq = typeof data.seq === 'number' ? data.seq : node.anchorSeq
|
|
389
|
+
if (typeof seq !== 'number') return null
|
|
390
|
+
if (hidden.has(seq)) return null
|
|
391
|
+
const entry = editable.get(seq)
|
|
392
|
+
if (!entry) return null
|
|
393
|
+
return {
|
|
394
|
+
seq,
|
|
395
|
+
turn: entry.turn,
|
|
396
|
+
messageId: entry.messageId,
|
|
397
|
+
text: typeof entry.text === 'string' ? entry.text : '',
|
|
398
|
+
attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Every surface seq a row stands for, so one rollback can hide the rows it
|
|
403
|
+
// shadowed however the host UI chose to group them.
|
|
404
|
+
function seqsFor(node) {
|
|
405
|
+
const data = node.data || {}
|
|
406
|
+
const out = []
|
|
407
|
+
const push = (value) => {
|
|
408
|
+
if (typeof value === 'number' && !out.includes(value)) out.push(value)
|
|
409
|
+
}
|
|
410
|
+
switch (node.kind) {
|
|
411
|
+
case 'user':
|
|
412
|
+
case 'context':
|
|
413
|
+
case 'steering':
|
|
414
|
+
push(data.seq)
|
|
415
|
+
break
|
|
416
|
+
case 'assistant-step':
|
|
417
|
+
push(node.anchorSeq)
|
|
418
|
+
if (data.finalNode) push(data.finalNode.seq)
|
|
419
|
+
break
|
|
420
|
+
case 'tool-call':
|
|
421
|
+
if (data.root) push(data.root.seq)
|
|
422
|
+
break
|
|
423
|
+
case 'turn-tail':
|
|
424
|
+
push(node.anchorSeq)
|
|
425
|
+
if (data.closing && data.closing.finalNode) push(data.closing.finalNode.seq)
|
|
426
|
+
break
|
|
427
|
+
case 'turn-process':
|
|
428
|
+
push(data.answerAnchorSeq)
|
|
429
|
+
break
|
|
430
|
+
default:
|
|
431
|
+
push(node.anchorSeq)
|
|
432
|
+
}
|
|
433
|
+
return out
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function isRowHidden(hidden, seqs) {
|
|
437
|
+
for (const seq of seqs) {
|
|
438
|
+
if (hidden.has(seq)) return true
|
|
439
|
+
}
|
|
440
|
+
return false
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// --- dom enhancement ------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
const rowActions = new WeakMap()
|
|
446
|
+
|
|
447
|
+
function setRowHidden(row, hide, animate) {
|
|
448
|
+
if (hide) {
|
|
449
|
+
if (row.dataset.dshetHidden === '1') return
|
|
450
|
+
row.dataset.dshetHidden = '1'
|
|
451
|
+
if (!animate || typeof requestAnimationFrame !== 'function') {
|
|
452
|
+
row.style.display = 'none'
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
const height = row.getBoundingClientRect().height
|
|
456
|
+
row.classList.add('dshet-collapsing')
|
|
457
|
+
row.style.height = `${height}px`
|
|
458
|
+
row.style.opacity = '1'
|
|
459
|
+
requestAnimationFrame(() => {
|
|
460
|
+
row.style.height = '0px'
|
|
461
|
+
row.style.opacity = '0'
|
|
462
|
+
row.style.marginTop = '0px'
|
|
463
|
+
row.style.marginBottom = '0px'
|
|
464
|
+
row.style.paddingTop = '0px'
|
|
465
|
+
row.style.paddingBottom = '0px'
|
|
466
|
+
})
|
|
467
|
+
window.setTimeout(() => {
|
|
468
|
+
if (row.dataset.dshetHidden !== '1') return
|
|
469
|
+
row.classList.remove('dshet-collapsing')
|
|
470
|
+
row.style.display = 'none'
|
|
471
|
+
}, 240)
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
if (row.dataset.dshetHidden !== '1') return
|
|
475
|
+
delete row.dataset.dshetHidden
|
|
476
|
+
row.classList.remove('dshet-collapsing')
|
|
477
|
+
row.style.display = ''
|
|
478
|
+
row.style.height = ''
|
|
479
|
+
row.style.opacity = ''
|
|
480
|
+
row.style.marginTop = ''
|
|
481
|
+
row.style.marginBottom = ''
|
|
482
|
+
row.style.paddingTop = ''
|
|
483
|
+
row.style.paddingBottom = ''
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function removeRowAction(row) {
|
|
487
|
+
const entry = rowActions.get(row)
|
|
488
|
+
if (!entry) return
|
|
489
|
+
entry.host.remove()
|
|
490
|
+
rowActions.delete(row)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function injectRowAction(row, target, controller, t) {
|
|
494
|
+
const label = t('action.edit')
|
|
495
|
+
let entry = rowActions.get(row)
|
|
496
|
+
if (!entry) {
|
|
497
|
+
const host = document.createElement('span')
|
|
498
|
+
host.className = 'dshet-action-host'
|
|
499
|
+
const button = document.createElement('button')
|
|
500
|
+
button.type = 'button'
|
|
501
|
+
button.className = 'dshet-action dshet-row-action'
|
|
502
|
+
button.innerHTML = ICON_MARKUP
|
|
503
|
+
host.appendChild(button)
|
|
504
|
+
entry = { host, button }
|
|
505
|
+
rowActions.set(row, entry)
|
|
506
|
+
}
|
|
507
|
+
const { host, button } = entry
|
|
508
|
+
if (button.getAttribute('aria-label') !== label) {
|
|
509
|
+
button.setAttribute('aria-label', label)
|
|
510
|
+
button.setAttribute('title', label)
|
|
511
|
+
}
|
|
512
|
+
button.onclick = (event) => {
|
|
513
|
+
event.preventDefault()
|
|
514
|
+
event.stopPropagation()
|
|
515
|
+
controller.open(target)
|
|
516
|
+
}
|
|
517
|
+
const anchor = row.querySelector('[class*="_actions"]')
|
|
518
|
+
if (anchor) {
|
|
519
|
+
host.classList.remove('dshet-floating')
|
|
520
|
+
row.classList.remove('dshet-row')
|
|
521
|
+
if (host.parentElement !== anchor) anchor.appendChild(host)
|
|
522
|
+
} else {
|
|
523
|
+
host.classList.add('dshet-floating')
|
|
524
|
+
row.classList.add('dshet-row')
|
|
525
|
+
if (host.parentElement !== row) row.appendChild(host)
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function clearEditor() {
|
|
530
|
+
const elements = document.querySelectorAll('.dshet-editor')
|
|
531
|
+
for (const element of elements) element.remove()
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// The editor is plain DOM on purpose: the host row is a React subtree and
|
|
535
|
+
// injecting a second controlled tree into it would fight its reconciler.
|
|
536
|
+
// The draft is mirrored into the controller on every input event, so a
|
|
537
|
+
// host re-render that wipes the textarea cannot lose what was typed.
|
|
538
|
+
// The re-render marker for the in-place editor.
|
|
539
|
+
//
|
|
540
|
+
// Every field `renderEditor` reads belongs here. A marker that only tracks
|
|
541
|
+
// the target makes the editor a dead end: the confirmation step publishes new
|
|
542
|
+
// state, the marker stays equal, the node is never rebuilt, and the button
|
|
543
|
+
// keeps offering "save" instead of advancing to "confirm" - the editor
|
|
544
|
+
// becomes unclickable from the user's side.
|
|
545
|
+
function editorMarker(target, view) {
|
|
546
|
+
return [
|
|
547
|
+
target.seq,
|
|
548
|
+
view.confirming ? 1 : 0,
|
|
549
|
+
view.pending ? 1 : 0,
|
|
550
|
+
view.failure === null ? '' : view.failure,
|
|
551
|
+
].join('|')
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function renderEditor(row, controller, view, t) {
|
|
555
|
+
const target = view.editing
|
|
556
|
+
const marker = editorMarker(target, view)
|
|
557
|
+
const existing = row.querySelector('.dshet-editor')
|
|
558
|
+
if (existing && existing.dataset.dshetFor === marker) return
|
|
559
|
+
|
|
560
|
+
clearEditor()
|
|
561
|
+
const box = document.createElement('div')
|
|
562
|
+
box.className = 'dshet-editor'
|
|
563
|
+
box.dataset.dshetFor = marker
|
|
564
|
+
|
|
565
|
+
const title = document.createElement('p')
|
|
566
|
+
title.className = 'dshet-editor-title'
|
|
567
|
+
title.textContent = view.confirming ? t('confirm.title') : t('editor.title')
|
|
568
|
+
box.appendChild(title)
|
|
569
|
+
|
|
570
|
+
const area = document.createElement('textarea')
|
|
571
|
+
area.rows = 4
|
|
572
|
+
area.value = controller.editorDraft
|
|
573
|
+
area.disabled = view.pending || view.confirming
|
|
574
|
+
area.spellcheck = false
|
|
575
|
+
area.setAttribute('aria-label', t('editor.title'))
|
|
576
|
+
if (!view.confirming) area.placeholder = t('editor.placeholder')
|
|
577
|
+
area.addEventListener('input', () => controller.setDraft(area.value))
|
|
578
|
+
box.appendChild(area)
|
|
579
|
+
|
|
580
|
+
const note = document.createElement('p')
|
|
581
|
+
note.className = 'dshet-note'
|
|
582
|
+
note.textContent = view.confirming ? t('confirm.body') : t('editor.hint')
|
|
583
|
+
box.appendChild(note)
|
|
584
|
+
|
|
585
|
+
if (target.attachments > 0) {
|
|
586
|
+
const warn = document.createElement('p')
|
|
587
|
+
warn.className = 'dshet-warn'
|
|
588
|
+
warn.textContent = t('editor.warn.attachments')
|
|
589
|
+
box.appendChild(warn)
|
|
590
|
+
}
|
|
591
|
+
if (typeof view.failure === 'string' && view.failure !== '') {
|
|
592
|
+
const error = document.createElement('p')
|
|
593
|
+
error.className = 'dshet-error'
|
|
594
|
+
error.setAttribute('role', 'status')
|
|
595
|
+
error.textContent = errorText(t, view.failure)
|
|
596
|
+
box.appendChild(error)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const footer = document.createElement('div')
|
|
600
|
+
footer.className = 'dshet-footer'
|
|
601
|
+
|
|
602
|
+
const cancel = document.createElement('button')
|
|
603
|
+
cancel.type = 'button'
|
|
604
|
+
cancel.className = 'dshet-btn'
|
|
605
|
+
cancel.textContent = t('editor.cancel')
|
|
606
|
+
cancel.disabled = view.pending
|
|
607
|
+
cancel.onclick = (event) => {
|
|
608
|
+
event.preventDefault()
|
|
609
|
+
event.stopPropagation()
|
|
610
|
+
if (view.confirming) controller.back()
|
|
611
|
+
else controller.close()
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const submit = document.createElement('button')
|
|
615
|
+
submit.type = 'button'
|
|
616
|
+
submit.className = 'dshet-btn dshet-btn-primary'
|
|
617
|
+
submit.textContent = view.pending
|
|
618
|
+
? t('editor.pending')
|
|
619
|
+
: view.confirming
|
|
620
|
+
? t('editor.review')
|
|
621
|
+
: t('editor.save')
|
|
622
|
+
submit.disabled = view.pending
|
|
623
|
+
submit.onclick = (event) => {
|
|
624
|
+
event.preventDefault()
|
|
625
|
+
event.stopPropagation()
|
|
626
|
+
if (view.confirming) controller.confirm()
|
|
627
|
+
else controller.review()
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
footer.appendChild(cancel)
|
|
631
|
+
footer.appendChild(submit)
|
|
632
|
+
box.appendChild(footer)
|
|
633
|
+
|
|
634
|
+
row.appendChild(box)
|
|
635
|
+
if (!view.confirming && !view.pending) {
|
|
636
|
+
try {
|
|
637
|
+
area.focus()
|
|
638
|
+
area.setSelectionRange(area.value.length, area.value.length)
|
|
639
|
+
} catch {
|
|
640
|
+
/* focus is best-effort */
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Ask the host again whenever the transcript gains or loses rows.
|
|
646
|
+
//
|
|
647
|
+
// A change in the number of transcript rows is the signal that the host's
|
|
648
|
+
// editable set may have moved on: a re-run appends a new prompt, which the
|
|
649
|
+
// host will happily let the user edit but this half has never asked about.
|
|
650
|
+
// Without this the plugin only ever worked once per page load - the second
|
|
651
|
+
// edit had no action to click because the editable set was still the one
|
|
652
|
+
// fetched at mount time.
|
|
653
|
+
function syncEditableSet(controller, rowCount) {
|
|
654
|
+
if (rowCount === controller.rowCount) return
|
|
655
|
+
controller.rowCount = rowCount
|
|
656
|
+
controller.load(true)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function applyDom(snapshot, view, controller, t) {
|
|
660
|
+
if (!snapshot || !snapshot.nodes || typeof snapshot.nodes.get !== 'function') return
|
|
661
|
+
const animate = controller.consumeAnimate()
|
|
662
|
+
const rows = document.querySelectorAll('[data-chat-flow-key]')
|
|
663
|
+
syncEditableSet(controller, rows.length)
|
|
664
|
+
for (const row of rows) {
|
|
665
|
+
if (!(row instanceof HTMLElement)) continue
|
|
666
|
+
const key = row.getAttribute('data-chat-flow-key')
|
|
667
|
+
if (!key) continue
|
|
668
|
+
const node = snapshot.nodes.get(key)
|
|
669
|
+
if (!node) continue
|
|
670
|
+
const hidden = isRowHidden(view.hidden, seqsFor(node))
|
|
671
|
+
setRowHidden(row, hidden, animate)
|
|
672
|
+
const target = hidden ? null : targetFor(node, view.editable, view.hidden)
|
|
673
|
+
if (target !== null) injectRowAction(row, target, controller, t)
|
|
674
|
+
else removeRowAction(row)
|
|
675
|
+
if (
|
|
676
|
+
!hidden &&
|
|
677
|
+
view.editing !== null &&
|
|
678
|
+
typeof view.editing.seq === 'number' &&
|
|
679
|
+
target !== null &&
|
|
680
|
+
target.seq === view.editing.seq
|
|
681
|
+
) {
|
|
682
|
+
renderEditor(row, controller, view, t)
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (view.editing === null) clearEditor()
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// --- react entries --------------------------------------------------------
|
|
689
|
+
|
|
690
|
+
function OverlayEntry({ useChat, useEditTurn, controller, t }) {
|
|
691
|
+
const snapshot = typeof useChat === 'function' ? useChat((state) => state) : undefined
|
|
692
|
+
const view = useEditTurn((state) => state)
|
|
693
|
+
|
|
694
|
+
react.useEffect(() => {
|
|
695
|
+
controller.load()
|
|
696
|
+
}, [controller])
|
|
697
|
+
|
|
698
|
+
react.useEffect(() => {
|
|
699
|
+
if (snapshot === undefined) return undefined
|
|
700
|
+
let scheduled = false
|
|
701
|
+
const run = () => {
|
|
702
|
+
scheduled = false
|
|
703
|
+
applyDom(snapshot, view, controller, t)
|
|
704
|
+
}
|
|
705
|
+
run()
|
|
706
|
+
const observer = new MutationObserver(() => {
|
|
707
|
+
if (scheduled) return
|
|
708
|
+
scheduled = true
|
|
709
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
|
|
710
|
+
else window.setTimeout(run, 16)
|
|
711
|
+
})
|
|
712
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
713
|
+
return () => {
|
|
714
|
+
observer.disconnect()
|
|
715
|
+
}
|
|
716
|
+
}, [snapshot, view, controller, t])
|
|
717
|
+
|
|
718
|
+
return jsxs(Fragment, {
|
|
719
|
+
children: [
|
|
720
|
+
typeof view.notice !== 'string' || view.notice === ''
|
|
721
|
+
? null
|
|
722
|
+
: jsx('div', {
|
|
723
|
+
className: 'dshet-notice',
|
|
724
|
+
role: 'status',
|
|
725
|
+
onClick: () => controller.dismissNotice(),
|
|
726
|
+
children: errorText(t, view.notice),
|
|
727
|
+
}),
|
|
728
|
+
],
|
|
729
|
+
})
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// --- plugin ---------------------------------------------------------------
|
|
733
|
+
|
|
734
|
+
function apply(ctx) {
|
|
735
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-edit-turn: dictionaries')
|
|
736
|
+
|
|
737
|
+
const controllers = new Map()
|
|
738
|
+
const controllerFor = (sessionId) => {
|
|
739
|
+
let controller = controllers.get(sessionId)
|
|
740
|
+
if (controller === undefined) {
|
|
741
|
+
controller = new EditController(sessionId)
|
|
742
|
+
controllers.set(sessionId, controller)
|
|
743
|
+
}
|
|
744
|
+
return controller
|
|
745
|
+
}
|
|
746
|
+
ctx.effect(
|
|
747
|
+
() => () => {
|
|
748
|
+
for (const controller of controllers.values()) controller.dispose()
|
|
749
|
+
controllers.clear()
|
|
750
|
+
},
|
|
751
|
+
'dsh-edit-turn: per-session controllers',
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
ctx.slots.inject('conversation.input.overlay', () =>
|
|
755
|
+
ctx.slots.register(
|
|
756
|
+
{
|
|
757
|
+
name: 'conversation.input.overlay',
|
|
758
|
+
id: 'edit-turn',
|
|
759
|
+
order: 9,
|
|
760
|
+
locale: NS,
|
|
761
|
+
inject: (sessionId) => ({ hooks: { editTurn: controllerFor(sessionId) }, controller: controllerFor(sessionId) }),
|
|
762
|
+
},
|
|
763
|
+
OverlayEntry,
|
|
764
|
+
),
|
|
765
|
+
)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
exports.apply = apply
|
|
769
|
+
exports.inject = ['slots', 'locale']
|
|
770
|
+
exports.PLUGIN_VERSION = PLUGIN_VERSION
|
|
771
|
+
return module.exports
|
|
772
|
+
},
|
|
773
|
+
})
|