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/HUMANS.txt +18 -0
- package/LICENSE +21 -0
- package/README.md +317 -0
- package/README.zh.md +284 -0
- package/cordis.patch.yml +17 -0
- package/lib/artifact-store.js +239 -0
- package/lib/client.bundle.js +637 -0
- package/lib/client.js +752 -0
- package/lib/dynamic-client.js +647 -0
- package/lib/dynamic-host.js +399 -0
- package/lib/host-core.js +379 -0
- package/lib/http.js +186 -0
- package/lib/index.js +75 -0
- package/lib/projection/versions.js +69 -0
- package/lib/types/client.d.ts +14 -0
- package/lib/types/index.d.ts +65 -0
- package/lib/version-index.js +310 -0
- package/lib/versioning.js +193 -0
- package/package.json +100 -0
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
|
+
}
|