dsh-retrace 0.3.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/lib/client.js ADDED
@@ -0,0 +1,752 @@
1
+ /**
2
+ * dsh-retrace — Client plugin entry (published form).
3
+ *
4
+ * Adds to the Web conversation view:
5
+ * 1. an action strip on assistant replies (撤回 / 重新生成) via the
6
+ * `conversation.chat.assistant-actions` list seat,
7
+ * 2. an action row under every user message (编辑 / 撤回) with an inline
8
+ * editor; after a recall the recalled text is echoed into the composer,
9
+ * 3. a `recall-marker` node that HIDES every shadowed message row from the
10
+ * flow (CSS `data-chat-anchor-key` rules) and renders a notice row with an
11
+ * optional "original input" comparison block,
12
+ * 4. two preference toggles under Settings → General (original-input
13
+ * comparison row, fresh-start editing) backed by localStorage.
14
+ *
15
+ * Operations reach the Host through the same-origin route
16
+ * `/api/plugins/retrace/*` registered by the Host half.
17
+ */
18
+ import { createElement, useEffect, useState } from 'react'
19
+
20
+ export const name = 'dsh-retrace'
21
+ export const inject = ['slots', 'locale', 'conversationEvents']
22
+
23
+ const NS = 'retrace'
24
+ const ROUTE_BASE = '/api/plugins/retrace'
25
+ const MARKER_PREFIX = 'retrace'
26
+ const CONFIG_KEY = 'dsh-retrace:config'
27
+
28
+ /** Simplified Chinese dictionary (key-set source of truth). */
29
+ const zh = {
30
+ 'action.edit': '编辑',
31
+ 'action.editAria': '编辑这条消息',
32
+ 'action.recall': '撤回',
33
+ 'action.recallAssistant': '撤回这条回复',
34
+ 'action.recallUser': '撤回这条消息',
35
+ 'action.regenerate': '重新生成',
36
+ 'action.send': '发送',
37
+ 'action.cancel': '取消',
38
+ 'marker.recall': '已撤回这条消息及其后的对话',
39
+ 'marker.recallMany': '已撤回 {count} 条消息',
40
+ 'marker.recallOne': '已撤回 1 条消息',
41
+ 'marker.edit': '已编辑此消息并重新发送,对话从新消息继续',
42
+ 'marker.regenerate': '已重新生成回复',
43
+ 'marker.originalLabel': '原输入',
44
+ 'marker.referenceHint': '点击展开查看原提问(仅作对照,不会进入模型上下文)',
45
+ 'options.title': '消息编辑插件',
46
+ 'options.showOriginalInput': '编辑后显示原提问对照',
47
+ 'options.editFromScratch': '编辑后从新对话开始(隐藏此前的消息)',
48
+ 'options.versioning': '版本与产物快照',
49
+ 'options.versioningDesc': '开:每次撤回/编辑记录一个版本(消息与触碰文件),提供时间线与产物回退;关:仅回退上下文,不记录版本、不追踪产物(最省资源)。',
50
+ 'options.git': '启用 git 集成',
51
+ 'options.gitDesc': '开:工作区是 git 仓库时用 git 记录与回退(不自动提交、不动你的分支),非仓库可在时间线里一键启用;关:一律用内置快照(存于 ~/.dsh),不触碰工作区 git 状态,功能等价。',
52
+ 'options.retention': '版本保留上限',
53
+ 'options.retentionDesc': '文件快照只保留最近 N 个版本,超出自动清理最旧的;时间线记录与审计痕迹始终保留。',
54
+ 'error.generic': '操作失败,请重试',
55
+ 'error.busy': '请先停止当前回复再操作',
56
+ }
57
+ /** English dictionary, checked complete against the zh key set. */
58
+ const en = {
59
+ 'action.edit': 'Edit',
60
+ 'action.editAria': 'Edit this message',
61
+ 'action.recall': 'Recall',
62
+ 'action.recallAssistant': 'Recall this reply',
63
+ 'action.recallUser': 'Recall this message',
64
+ 'action.regenerate': 'Regenerate',
65
+ 'action.send': 'Send',
66
+ 'action.cancel': 'Cancel',
67
+ 'marker.recall': 'This message and the following conversation were recalled',
68
+ 'marker.recallMany': '{count} messages were recalled',
69
+ 'marker.recallOne': '1 message recalled',
70
+ 'marker.edit': 'Edited and re-sent; the conversation continues from the new message',
71
+ 'marker.regenerate': 'Reply regenerated',
72
+ 'marker.originalLabel': 'Original input',
73
+ 'marker.referenceHint': 'Click to expand the original input (reference only, never sent to the model)',
74
+ 'options.title': 'Message editor plugin',
75
+ 'options.showOriginalInput': 'Show the original input after editing',
76
+ 'options.editFromScratch': 'Start a fresh conversation after editing (hide earlier messages)',
77
+ 'options.versioning': 'Version & artifact snapshots',
78
+ 'options.versioningDesc': 'On: every recall/edit records a version (messages and touched files) powering the timeline and artifact rollback. Off: only rewinds context — no version records, no artifact tracking (lightest).',
79
+ 'options.git': 'Git integration',
80
+ 'options.gitDesc': 'On: uses git to record and roll back when the workspace is a repository (never auto-commits, never touches your branches); non-repo workspaces can enable git from the timeline. Off: built-in snapshots under ~/.dsh only — the plugin never touches the workspace git state; equivalent features.',
81
+ 'options.retention': 'Version retention limit',
82
+ 'options.retentionDesc': 'File snapshots are kept for the most recent N versions; older ones are pruned automatically (timeline records and the audit trail are always kept).',
83
+ 'error.generic': 'Operation failed; please try again',
84
+ 'error.busy': 'Stop the current reply before recalling or editing',
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Durable-surface helpers (mirror of @deepseek-ai/dsh-session/surface).
89
+ // ---------------------------------------------------------------------------
90
+ const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result'])
91
+
92
+ function isReplacementSurfaceEvent(event) {
93
+ return SURFACE_TYPES.has(event.type) && event.surfaceOp !== undefined && event.surfaceOp !== 'append'
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Wire call
98
+ // ---------------------------------------------------------------------------
99
+ // The published client calls the same-origin HTTP route registered by the Host
100
+ // half. The generated dynamic client (scripts/generate-dynamic.mjs) swaps in
101
+ // `host.call` before apply runs, so ONE source serves both runtimes and the
102
+ // two can never drift apart.
103
+ let wire = null
104
+
105
+ export function __setMessageEditorWire(fn) {
106
+ wire = fn
107
+ }
108
+
109
+ function callOp(op, payload) {
110
+ if (typeof wire === 'function') return wire(op, payload)
111
+ return fetch(`${ROUTE_BASE}/${op}`, {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/json', ...retraceConfigHeaders() },
114
+ body: JSON.stringify(payload),
115
+ }).then((res) => {
116
+ if (res.status < 200 || res.status >= 300) throw new Error(`HTTP ${res.status}`)
117
+ return res.json()
118
+ })
119
+ }
120
+
121
+ /** The localStorage plugin config, carried to the Host on every request
122
+ * (PLAN §4.6: the host honors it per request and does not persist it). */
123
+ function retraceConfigHeaders() {
124
+ const { versioning, git, retentionLimit } = getConfig()
125
+ return { 'x-retrace-config': JSON.stringify({ versioning, git, retentionLimit }) }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Plugin preferences (localStorage-backed, reactive)
130
+ // ---------------------------------------------------------------------------
131
+ const CONFIG_DEFAULTS = { showOriginalInput: true, editFromScratch: true, versioning: true, git: true, retentionLimit: 50 }
132
+ const configListeners = new Set()
133
+ let configCache = readConfig()
134
+
135
+ /** resendMessageId -> the exact text that edit replaced (most recent, host-authoritative). */
136
+ const editReferences = new Map()
137
+
138
+ function readConfig() {
139
+ try {
140
+ const raw = localStorage.getItem(CONFIG_KEY)
141
+ return { ...CONFIG_DEFAULTS, ...(raw ? JSON.parse(raw) : {}) }
142
+ } catch {
143
+ return { ...CONFIG_DEFAULTS }
144
+ }
145
+ }
146
+ function getConfig() {
147
+ return configCache
148
+ }
149
+ function setConfig(patch) {
150
+ configCache = { ...configCache, ...patch }
151
+ try {
152
+ localStorage.setItem(CONFIG_KEY, JSON.stringify(configCache))
153
+ } catch { /* storage unavailable */ }
154
+ for (const listener of configListeners) listener(configCache)
155
+ }
156
+ function subscribeConfig(listener) {
157
+ configListeners.add(listener)
158
+ return () => {
159
+ configListeners.delete(listener)
160
+ }
161
+ }
162
+ function useConfig() {
163
+ const [, force] = useState(0)
164
+ useEffect(() => subscribeConfig(() => force((x) => x + 1)), [])
165
+ return getConfig()
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Conversation node definitions
170
+ // ---------------------------------------------------------------------------
171
+ function chatNodeLike(context, kind, anchorSeq, data) {
172
+ return {
173
+ key: context.key,
174
+ kind,
175
+ id: context.id,
176
+ target: 'chat',
177
+ anchorSeq,
178
+ location: context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' },
179
+ visibility: 'visible',
180
+ data,
181
+ }
182
+ }
183
+
184
+ /** One small action row per user-sent message (edit / recall). */
185
+ const userActionsDefinition = {
186
+ kind: 'retrace-actions',
187
+ target: 'chat',
188
+ match: (event) => (
189
+ event.type === 'user/message'
190
+ && event.surfaceOp === 'append'
191
+ && event.data.source?.kind === 'user'
192
+ ? { id: String(event.data.id), role: 'start' }
193
+ : null
194
+ ),
195
+ start: (_context, match) => {
196
+ const event = match.event
197
+ return {
198
+ seq: event.seq,
199
+ time: event.time,
200
+ messageId: String(event.data.id),
201
+ content: event.data.content,
202
+ }
203
+ },
204
+ update: (context) => context.state,
205
+ buildViewNode: (context) => {
206
+ if (context.state === undefined) return null
207
+ return chatNodeLike(context, 'user-actions', context.state.seq, context.state)
208
+ },
209
+ }
210
+
211
+ /**
212
+ * The "original input" reference block for an edit re-send. Anchored just
213
+ * before the message (`seq - 0.5`) so it renders directly ABOVE the new
214
+ * input; the action buttons stay below the bubble in `user-actions`.
215
+ */
216
+ const userReferenceDefinition = {
217
+ kind: 'retrace-reference',
218
+ target: 'chat',
219
+ match: (event) => (
220
+ event.type === 'user/message'
221
+ && event.surfaceOp === 'append'
222
+ && event.data.source?.kind === 'user'
223
+ ? { id: `ref:${String(event.data.id)}`, role: 'start' }
224
+ : null
225
+ ),
226
+ start: (_context, match) => {
227
+ const event = match.event
228
+ return {
229
+ seq: event.seq,
230
+ time: event.time,
231
+ messageId: String(event.data.id),
232
+ content: event.data.content,
233
+ }
234
+ },
235
+ update: (context) => context.state,
236
+ buildViewNode: (context) => {
237
+ if (context.state === undefined) return null
238
+ return chatNodeLike(context, 'retrace-reference', context.state.seq - 0.5, context.state)
239
+ },
240
+ }
241
+
242
+ function markerOpFromId(id) {
243
+ if (id.startsWith(`${MARKER_PREFIX}-recall-`)) return 'recall'
244
+ if (id.startsWith(`${MARKER_PREFIX}-edit-`)) return 'edit'
245
+ if (id.startsWith(`${MARKER_PREFIX}-regenerate-`)) return 'regenerate'
246
+ return 'edit'
247
+ }
248
+
249
+ /**
250
+ * The recall/edit/regenerate marker node. Renders a notice row and injects CSS
251
+ * that hides every shadowed message row (they stay in the durable log as an
252
+ * audit trail but disappear from the flow, so view and model context agree).
253
+ */
254
+ const recallMarkerDefinition = {
255
+ kind: 'recall-marker',
256
+ target: 'chat',
257
+ match: (event) => {
258
+ if (event.type !== 'assistant/message' || !isReplacementSurfaceEvent(event)) return null
259
+ const id = event.data?.message?.id
260
+ if (typeof id !== 'string' || !id.startsWith(`${MARKER_PREFIX}-`)) return null
261
+ return { id: `marker:${id}`, role: 'start' }
262
+ },
263
+ start: (_context, match) => {
264
+ const event = match.event
265
+ return {
266
+ seq: event.seq,
267
+ time: event.time,
268
+ op: markerOpFromId(String(event.data.message.id)),
269
+ shadowedSeqs: Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs.slice() : [],
270
+ targetSeq: event.data?.editor?.targetSeq,
271
+ text: event.data?.editor?.text,
272
+ }
273
+ },
274
+ update: (context) => context.state,
275
+ buildViewNode: (context) => {
276
+ if (context.state === undefined) return null
277
+ return chatNodeLike(context, 'recall-marker', context.state.seq, context.state)
278
+ },
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Shared selector helpers
283
+ // ---------------------------------------------------------------------------
284
+ function textOf(content) {
285
+ if (!Array.isArray(content)) return ''
286
+ return content
287
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
288
+ .map((block) => block.text)
289
+ .join('\n')
290
+ }
291
+
292
+ /** The durable seq of the finalized assistant message with `messageId`. */
293
+ function useMessageSeq(useSession, messageId) {
294
+ return useSession((snapshot) => {
295
+ for (const node of snapshot.chat.nodes.values()) {
296
+ if (node.kind === 'assistant-step' && node.data?.finalNode?.messageId === messageId) {
297
+ return node.data.finalNode.seq
298
+ }
299
+ }
300
+ return undefined
301
+ })
302
+ }
303
+
304
+ /** True when `seq` was shadowed by any recall/edit/regenerate marker. */
305
+ function useShadowed(useSession, seq) {
306
+ return useSession((snapshot) => {
307
+ if (seq === undefined || seq === null) return false
308
+ for (const node of snapshot.chat.nodes.values()) {
309
+ if (node.kind === 'recall-marker' && Array.isArray(node.data?.shadowedSeqs)
310
+ && node.data.shadowedSeqs.includes(seq)) {
311
+ return true
312
+ }
313
+ }
314
+ return false
315
+ })
316
+ }
317
+
318
+ /**
319
+ * Every chat-node key that should disappear when `shadowedSeqs` are recalled:
320
+ * the shadowed message rows themselves, plus the per-turn action row (copy /
321
+ * feedback / branch) when its finalized assistant reply is among them.
322
+ */
323
+ function useHiddenKeys(useSession, shadowedSeqs) {
324
+ return useSession((snapshot) => {
325
+ if (!Array.isArray(shadowedSeqs) || shadowedSeqs.length === 0) return null
326
+ const hidden = new Set(shadowedSeqs)
327
+ const keys = []
328
+ for (const node of snapshot.chat.nodes.values()) {
329
+ if (node.kind === 'recall-marker') continue
330
+ if (node.kind === 'turn-tail') {
331
+ // `closing` is the finalized assistant-step *data*; the message seq
332
+ // lives on its `finalNode` (matching how the app reads `closing.finalNode.seq`).
333
+ const closingSeq = node.data?.closing?.finalNode?.seq
334
+ if (typeof closingSeq === 'number' && hidden.has(closingSeq)) keys.push(node.key)
335
+ continue
336
+ }
337
+ if (node.kind === 'tool-call') {
338
+ // Tool rows anchor at the tool/call event seq, which is a log-only
339
+ // event and never a surface node, so it cannot appear in shadowedSeqs.
340
+ // Match the settled result's surface seq (root.seq) instead.
341
+ const resultSeq = node.data?.root?.seq
342
+ if (typeof resultSeq === 'number' && hidden.has(resultSeq)) keys.push(node.key)
343
+ continue
344
+ }
345
+ if (typeof node.anchorSeq === 'number' && hidden.has(node.anchorSeq)) keys.push(node.key)
346
+ }
347
+ return keys.length === 0 ? null : keys
348
+ })
349
+ }
350
+
351
+ /** The marker notice disappears once the user keeps typing after the rewind. */
352
+ function useMarkerDismissed(useSession, markerSeq, op) {
353
+ return useSession((snapshot) => {
354
+ if (typeof markerSeq !== 'number') return false
355
+ let after = 0
356
+ for (const node of snapshot.chat.nodes.values()) {
357
+ if (node.kind === 'user-actions' && typeof node.data?.seq === 'number' && node.data.seq > markerSeq) {
358
+ after += 1
359
+ }
360
+ }
361
+ // The edit marker's own re-send message follows it automatically; the notice
362
+ // stays until the user sends ANOTHER message after the edit.
363
+ return op === 'edit' ? after >= 2 : after >= 1
364
+ })
365
+ }
366
+
367
+ /**
368
+ * For one user message, the original text of the edit that produced it: the
369
+ * nearest preceding edit marker with no other user message in between (i.e.
370
+ * this message is the automatic re-send after an edit).
371
+ */
372
+ function useEditReference(useSession, mySeq) {
373
+ return useSession((snapshot) => {
374
+ if (typeof mySeq !== 'number') return null
375
+ let latestMarkerSeq = -1
376
+ let referenceText = null
377
+ let prevUserSeq = -1
378
+ for (const node of snapshot.chat.nodes.values()) {
379
+ if (node.kind === 'recall-marker' && node.data?.op === 'edit' && typeof node.data.seq === 'number'
380
+ && node.data.seq < mySeq && node.data.seq > latestMarkerSeq) {
381
+ latestMarkerSeq = node.data.seq
382
+ referenceText = typeof node.data.text === 'string' && node.data.text.length > 0 ? node.data.text : null
383
+ }
384
+ if (node.kind === 'user-actions' && typeof node.data?.seq === 'number'
385
+ && node.data.seq < mySeq && node.data.seq > prevUserSeq) {
386
+ prevUserSeq = node.data.seq
387
+ }
388
+ }
389
+ if (latestMarkerSeq === -1 || referenceText === null) return null
390
+ if (prevUserSeq > latestMarkerSeq) return null
391
+ return referenceText
392
+ })
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Components
397
+ // ---------------------------------------------------------------------------
398
+
399
+ /** 撤回 / 重新生成 strip inside a finalized assistant reply's IconActions row. */
400
+ function AssistantActions({ messageId, sessionId, useSession, t }) {
401
+ const seq = useMessageSeq(useSession, messageId)
402
+ const shadowed = useShadowed(useSession, seq)
403
+ const [busy, setBusy] = useState(false)
404
+ const [failure, setFailure] = useState(null)
405
+ if (shadowed || seq === undefined) return null
406
+
407
+ const run = (op) => {
408
+ setBusy(true)
409
+ setFailure(null)
410
+ callOp(op, { sessionId, messageId }).then(
411
+ (result) => {
412
+ setBusy(false)
413
+ if (!result || result.ok !== true) {
414
+ const message = result?.error?.message || 'Operation failed; please try again'
415
+ const code = result?.error?.code
416
+ setFailure(code === 'agent-busy' ? t('error.busy') : message)
417
+ }
418
+ },
419
+ (error) => {
420
+ setBusy(false)
421
+ setFailure(error?.message ?? t('error.generic'))
422
+ },
423
+ )
424
+ }
425
+
426
+ return createElement('span', { className: 'dsh-rt-strip' }, [
427
+ createElement('button', {
428
+ key: 'recall',
429
+ type: 'button',
430
+ className: 'dsh-rt-icon',
431
+ title: t('action.recallAssistant'),
432
+ 'aria-label': t('action.recallAssistant'),
433
+ disabled: busy,
434
+ onClick: () => run('recall'),
435
+ }, '↩'),
436
+ createElement('button', {
437
+ key: 'regenerate',
438
+ type: 'button',
439
+ className: 'dsh-rt-icon',
440
+ title: t('action.regenerate'),
441
+ 'aria-label': t('action.regenerate'),
442
+ disabled: busy,
443
+ onClick: () => run('regenerate'),
444
+ }, '↻'),
445
+ failure !== null && createElement('span', { key: 'error', className: 'dsh-rt-error', role: 'status' }, failure),
446
+ ])
447
+ }
448
+
449
+ /** 原输入 reference block, rendered just above the re-sent message. */
450
+ function ReferenceRow({ node, useSession, t }) {
451
+ const { seq, messageId } = node.data
452
+ const shadowed = useShadowed(useSession, seq)
453
+ const markerRef = useEditReference(useSession, seq)
454
+ const referenceText = editReferences.get(messageId) ?? markerRef
455
+ const config = useConfig()
456
+ if (shadowed) return null
457
+ if (referenceText === null || !config.showOriginalInput) return null
458
+ return createElement('div', { className: 'dsh-rt-user-row' }, [
459
+ createElement('details', { className: 'dsh-rt-reference' }, [
460
+ createElement('summary', { title: t('marker.referenceHint') },
461
+ `${t('marker.originalLabel')}:${referenceText.length > 60 ? `${referenceText.slice(0, 60)}…` : referenceText}`),
462
+ createElement('div', { className: 'dsh-rt-reference-text' }, referenceText),
463
+ ]),
464
+ ])
465
+ }
466
+
467
+ /** 编辑 / 撤回 action row under one user message; recall echoes into the composer. */
468
+ function UserActionsRow({ node, sessionId, useSession, inputActions, t }) {
469
+ const { seq, messageId, content } = node.data
470
+ const shadowed = useShadowed(useSession, seq)
471
+ const [editing, setEditing] = useState(false)
472
+ const [draft, setDraft] = useState('')
473
+ const [busy, setBusy] = useState(false)
474
+ const [failure, setFailure] = useState(null)
475
+ if (shadowed) return null
476
+
477
+ const openEditor = () => {
478
+ setDraft(textOf(content))
479
+ setFailure(null)
480
+ setEditing(true)
481
+ }
482
+ const closeEditor = () => {
483
+ setEditing(false)
484
+ setFailure(null)
485
+ }
486
+ const settle = (result, op) => {
487
+ setBusy(false)
488
+ if (!result || result.ok !== true) {
489
+ const code = result?.error?.code
490
+ setFailure(code === 'agent-busy' ? t('error.busy') : (result?.error?.message ?? t('error.generic')))
491
+ return
492
+ }
493
+ if (op === 'recall') {
494
+ const echoed = typeof result.value?.text === 'string' && result.value.text.length > 0
495
+ ? result.value.text
496
+ : textOf(content)
497
+ if (echoed && inputActions && typeof inputActions.setDraft === 'function') {
498
+ inputActions.setDraft(echoed)
499
+ }
500
+ return
501
+ }
502
+ if (op === 'editAndResend') {
503
+ if (result.value?.resendMessageId && typeof result.value?.originalText === 'string') {
504
+ editReferences.set(result.value.resendMessageId, result.value.originalText)
505
+ }
506
+ setEditing(false)
507
+ }
508
+ }
509
+ const run = (op, extra = {}) => {
510
+ setBusy(true)
511
+ setFailure(null)
512
+ callOp(op, { sessionId, messageId, ...extra }).then(
513
+ (result) => settle(result, op),
514
+ (error) => {
515
+ setBusy(false)
516
+ setFailure(error?.message ?? t('error.generic'))
517
+ },
518
+ )
519
+ }
520
+
521
+ return createElement('div', { className: 'dsh-rt-user-row' }, [
522
+ editing
523
+ ? createElement('div', { key: 'editor', className: 'dsh-rt-editor' }, [
524
+ createElement('textarea', {
525
+ key: 'input',
526
+ className: 'dsh-rt-textarea',
527
+ 'aria-label': t('action.editAria'),
528
+ value: draft,
529
+ rows: 3,
530
+ onChange: (event) => setDraft(event.target.value),
531
+ }),
532
+ createElement('div', { key: 'buttons', className: 'dsh-rt-editor-buttons' }, [
533
+ createElement('button', {
534
+ key: 'send',
535
+ type: 'button',
536
+ className: 'dsh-rt-editor-send',
537
+ disabled: busy || draft.trim().length === 0,
538
+ onClick: () => run('editAndResend', {
539
+ text: draft.trim(),
540
+ fromScratch: getConfig().editFromScratch,
541
+ }),
542
+ }, t('action.send')),
543
+ createElement('button', {
544
+ key: 'cancel',
545
+ type: 'button',
546
+ className: 'dsh-rt-editor-cancel',
547
+ disabled: busy,
548
+ onClick: closeEditor,
549
+ }, t('action.cancel')),
550
+ ]),
551
+ ])
552
+ : createElement('span', { key: 'row', className: 'dsh-rt-user-actions' }, [
553
+ createElement('button', {
554
+ key: 'edit',
555
+ type: 'button',
556
+ className: 'dsh-rt-chip',
557
+ title: t('action.edit'),
558
+ disabled: busy,
559
+ onClick: openEditor,
560
+ }, t('action.edit')),
561
+ createElement('button', {
562
+ key: 'recall',
563
+ type: 'button',
564
+ className: 'dsh-rt-chip',
565
+ title: t('action.recallUser'),
566
+ disabled: busy,
567
+ onClick: () => run('recall'),
568
+ }, t('action.recall')),
569
+ ]),
570
+ failure !== null && createElement('div', { key: 'error', className: 'dsh-rt-error', role: 'status' }, failure),
571
+ ])
572
+ }
573
+
574
+ /** The transient notice row: hides shadowed content, dismissed after the user keeps typing. */
575
+ function RecallMarkerRow({ node, useSession, t }) {
576
+ const { seq, op, shadowedSeqs } = node.data
577
+ const dismissed = useMarkerDismissed(useSession, seq, op)
578
+ const hiddenKeys = useHiddenKeys(useSession, shadowedSeqs)
579
+
580
+ // The hide rules must stay mounted even after the notice is dismissed,
581
+ // otherwise the recalled message would reappear.
582
+ const css = hiddenKeys === null
583
+ ? null
584
+ : hiddenKeys.map((key) => `[data-chat-anchor-key=${JSON.stringify(key)}]{display:none!important}`).join('')
585
+ const count = Array.isArray(shadowedSeqs) ? shadowedSeqs.length : 0
586
+ const label = op === 'recall'
587
+ ? (count > 1 ? t('marker.recallMany', { count }) : t('marker.recallOne'))
588
+ : op === 'regenerate' ? t('marker.regenerate') : t('marker.edit')
589
+
590
+ return createElement('div', { className: 'dsh-rt-marker-block', 'data-dismissed': dismissed || undefined }, [
591
+ css !== null && createElement('style', { key: 'hide', dangerouslySetInnerHTML: { __html: css } }),
592
+ !dismissed && createElement('div', { key: 'label', className: 'dsh-rt-marker', role: 'status' }, label),
593
+ ])
594
+ }
595
+
596
+ /** Settings → General: the plugin's two preference toggles. */
597
+ function OptionsRow({ t }) {
598
+ const config = useConfig()
599
+ const toggle = (key) => (event) => setConfig({ [key]: event.target.checked })
600
+ const optionRow = (key, labelKey, descKey) => createElement('label', { key, className: 'dsh-rt-option' }, [
601
+ createElement('input', { type: 'checkbox', checked: config[key], onChange: toggle(key) }),
602
+ createElement('span', { className: 'dsh-rt-option-text' }, [
603
+ createElement('span', { className: 'dsh-rt-option-label' }, t(labelKey)),
604
+ createElement('span', { className: 'dsh-rt-option-desc' }, t(descKey)),
605
+ ]),
606
+ ])
607
+ return createElement('div', { className: 'dsh-rt-options' }, [
608
+ createElement('div', { key: 'title', className: 'dsh-rt-options-title' }, t('options.title')),
609
+ createElement('label', { key: 'original', className: 'dsh-rt-option' }, [
610
+ createElement('input', {
611
+ type: 'checkbox',
612
+ checked: config.showOriginalInput,
613
+ onChange: toggle('showOriginalInput'),
614
+ }),
615
+ createElement('span', null, t('options.showOriginalInput')),
616
+ ]),
617
+ createElement('label', { key: 'fresh', className: 'dsh-rt-option' }, [
618
+ createElement('input', {
619
+ type: 'checkbox',
620
+ checked: config.editFromScratch,
621
+ onChange: toggle('editFromScratch'),
622
+ }),
623
+ createElement('span', null, t('options.editFromScratch')),
624
+ ]),
625
+ optionRow('versioning', 'options.versioning', 'options.versioningDesc'),
626
+ optionRow('git', 'options.git', 'options.gitDesc'),
627
+ createElement('div', { key: 'retention', className: 'dsh-rt-option dsh-rt-option-number' }, [
628
+ createElement('span', { className: 'dsh-rt-option-text' }, [
629
+ createElement('span', { className: 'dsh-rt-option-label' }, t('options.retention')),
630
+ createElement('span', { className: 'dsh-rt-option-desc' }, t('options.retentionDesc')),
631
+ ]),
632
+ createElement('input', {
633
+ type: 'number',
634
+ min: 5,
635
+ max: 500,
636
+ step: 5,
637
+ className: 'dsh-rt-retention-input',
638
+ value: config.retentionLimit,
639
+ onChange: (event) => {
640
+ const value = Math.max(1, Math.min(1000, Number(event.target.value) || 50))
641
+ setConfig({ retentionLimit: value })
642
+ },
643
+ }),
644
+ ]),
645
+ ])
646
+ }
647
+
648
+ // ---------------------------------------------------------------------------
649
+ // Styles (plain injected <style>; removed with the plugin)
650
+ // ---------------------------------------------------------------------------
651
+ const STYLE_ID = 'dsh-retrace-css'
652
+ const CSS = `
653
+ .dsh-rt-strip{display:inline-flex;align-items:center;gap:2px}
654
+ .dsh-rt-icon{width:28px;height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:transparent;border:none;border-radius:28px;display:inline-flex;justify-content:center;align-items:center;padding:0;font-size:14px;line-height:1}
655
+ .dsh-rt-icon:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
656
+ .dsh-rt-icon:disabled{opacity:.4;cursor:default}
657
+ .dsh-rt-user-row{display:flex;flex-direction:column;align-items:flex-end;gap:4px;margin-top:2px}
658
+ .dsh-rt-user-actions{display:inline-flex;gap:6px}
659
+ .dsh-rt-chip{color:var(--dsw-alias-label-tertiary);cursor:pointer;background:var(--dsw-alias-interactive-bg-hover);border:none;border-radius:12px;padding:2px 10px;font-size:12px;line-height:20px}
660
+ .dsh-rt-chip:hover:not(:disabled){color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover-solid)}
661
+ .dsh-rt-chip:disabled{opacity:.5;cursor:default}
662
+ .dsh-rt-editor{display:flex;flex-direction:column;gap:6px;width:min(525px,82%);border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);border-radius:12px;padding:8px}
663
+ .dsh-rt-textarea{resize:vertical;width:100%;box-sizing:border-box;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-elevated);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;padding:8px 10px;font:inherit;font-size:14px;line-height:20px}
664
+ .dsh-rt-textarea:focus{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary)}
665
+ .dsh-rt-editor-buttons{display:flex;justify-content:flex-end;gap:8px}
666
+ .dsh-rt-editor-send{color:#fff;cursor:pointer;background:var(--dsw-alias-button-info-fill);border:none;border-radius:999px;padding:4px 16px;font-size:13px;line-height:20px}
667
+ .dsh-rt-editor-send:hover:not(:disabled){background:var(--dsw-alias-button-info-hover)}
668
+ .dsh-rt-editor-send:disabled{opacity:.4;cursor:default}
669
+ .dsh-rt-editor-cancel{color:var(--dsw-alias-label-secondary);cursor:pointer;background:transparent;border:none;border-radius:999px;padding:4px 12px;font-size:13px;line-height:20px}
670
+ .dsh-rt-editor-cancel:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
671
+ .dsh-rt-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;max-width:min(525px,82%)}
672
+ .dsh-rt-marker-block{display:flex;flex-direction:column;align-items:center;gap:4px;width:100%;max-width:var(--dsh-chat-content-width);box-sizing:border-box;margin:0 auto;padding:2px 0}
673
+ .dsh-rt-marker{text-align:center;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:20px}
674
+ .dsh-rt-reference{width:min(525px,82%);box-sizing:border-box;border:1px dashed var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);border-radius:10px;padding:2px 12px}
675
+ .dsh-rt-reference summary{color:var(--dsw-alias-label-caption);cursor:pointer;user-select:none;font-size:12px;line-height:22px;list-style:none;display:inline-flex;align-items:center;gap:6px;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
676
+ .dsh-rt-reference summary::-webkit-details-marker{display:none}
677
+ .dsh-rt-reference summary:before{content:"▸";transition:transform .12s;font-size:10px}
678
+ .dsh-rt-reference[open] summary:before{transform:rotate(90deg)}
679
+ .dsh-rt-reference summary:hover{color:var(--dsw-alias-label-secondary)}
680
+ .dsh-rt-reference-text{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;white-space:pre-wrap;overflow-wrap:anywhere;padding:2px 0 6px}
681
+ .dsh-rt-options{display:flex;flex-direction:column;gap:8px;padding:2px 0}
682
+ .dsh-rt-options-title{color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:600;line-height:20px}
683
+ .dsh-rt-option{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;cursor:pointer}
684
+ .dsh-rt-option-text{display:flex;flex-direction:column;gap:1px;min-width:0}
685
+ .dsh-rt-option-label{color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}
686
+ .dsh-rt-option-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:17px}
687
+ .dsh-rt-option-number{align-items:flex-start}
688
+ .dsh-rt-retention-input{width:64px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-elevated);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:2px 6px;font:inherit;font-size:13px;outline:none;margin-top:1px}
689
+ .dsh-rt-retention-input:focus{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary)}
690
+ .dsh-rt-option input{accent-color:var(--dsw-alias-state-business-primary)}
691
+ `
692
+
693
+ function ensureStyle() {
694
+ if (typeof document === 'undefined') return () => {}
695
+ if (document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`)) return () => {}
696
+ const tag = document.createElement('style')
697
+ tag.dataset.plugin = 'dsh-retrace'
698
+ tag.dataset.pluginCss = STYLE_ID
699
+ tag.textContent = CSS
700
+ document.head.appendChild(tag)
701
+ return () => {
702
+ tag.remove()
703
+ }
704
+ }
705
+
706
+ // ---------------------------------------------------------------------------
707
+ // Plugin
708
+ // ---------------------------------------------------------------------------
709
+ export function apply(ctx) {
710
+ const disposeStyle = ensureStyle()
711
+ ctx.effect(() => () => disposeStyle(), 'dsh-retrace: styles')
712
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-retrace: dictionaries')
713
+
714
+ const conversationEvents = ctx.get('conversationEvents')
715
+ if (conversationEvents) {
716
+ conversationEvents.register(userActionsDefinition)
717
+ conversationEvents.register(userReferenceDefinition)
718
+ conversationEvents.register(recallMarkerDefinition)
719
+ }
720
+
721
+ ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({
722
+ name: 'conversation.chat.assistant-actions',
723
+ id: 'retrace',
724
+ order: 20,
725
+ locale: NS,
726
+ }, AssistantActions))
727
+
728
+ ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
729
+ name: 'conversation.chat.node',
730
+ key: 'user-actions',
731
+ locale: NS,
732
+ }, UserActionsRow))
733
+
734
+ ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
735
+ name: 'conversation.chat.node',
736
+ key: 'retrace-reference',
737
+ locale: NS,
738
+ }, ReferenceRow))
739
+
740
+ ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
741
+ name: 'conversation.chat.node',
742
+ key: 'recall-marker',
743
+ locale: NS,
744
+ }, RecallMarkerRow))
745
+
746
+ ctx.slots.inject('settings.general.item', () => ctx.slots.register({
747
+ name: 'settings.general.item',
748
+ id: 'retrace',
749
+ order: 30,
750
+ locale: NS,
751
+ }, OptionsRow))
752
+ }