dsh-last-turn-delete 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.i18n.yaml +5 -0
- package/README.md +74 -0
- package/README.zh.md +74 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +704 -0
- package/lib/core.js +252 -0
- package/lib/index.js +338 -0
- package/package.json +57 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-last-turn-delete — browser half.
|
|
3
|
+
*
|
|
4
|
+
* A trash-can button (the same IconTrashOutline16 the pending-message queue
|
|
5
|
+
* uses) sits in the message action strip of the newest deletable item — the
|
|
6
|
+
* last user message, or a failed `/compact` card. The button only appears
|
|
7
|
+
* after the turn has fully finished (the host reports whether the agent is
|
|
8
|
+
* still running), and its resting/hover look is the native action button's
|
|
9
|
+
* (the class is cloned from the neighboring copy button; no custom colors).
|
|
10
|
+
*
|
|
11
|
+
* Clicking opens a small inline-styled confirm bubble (删除 / 取消). On
|
|
12
|
+
* confirm, the host soft-deletes the target — a user message shadows that
|
|
13
|
+
* turn out of the model surface; a failed compaction is recorded in the
|
|
14
|
+
* plugin's sidecar. The button then walks to the previous item, all the way
|
|
15
|
+
* to a real compact boundary or until nothing is left. Soft-deleted spans are
|
|
16
|
+
* dimmed in place; durable logs are never rewritten.
|
|
17
|
+
*
|
|
18
|
+
* Diagnostics: `window.__dshLastTurnDelete.diagnose()`.
|
|
19
|
+
*/
|
|
20
|
+
window.__ModuleLoader__.load({
|
|
21
|
+
id: "dsh-last-turn-delete",
|
|
22
|
+
factory: (require) => {
|
|
23
|
+
var module = { exports: {} };
|
|
24
|
+
var exports = module.exports;
|
|
25
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
26
|
+
const react = require("react");
|
|
27
|
+
|
|
28
|
+
// ── shared icon modules (seed words) ───────────────────────────────
|
|
29
|
+
let Primitives = null;
|
|
30
|
+
let createRootHost = null;
|
|
31
|
+
try {
|
|
32
|
+
Primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
33
|
+
} catch {}
|
|
34
|
+
try {
|
|
35
|
+
createRootHost = require("react-dom/client").createRoot;
|
|
36
|
+
} catch {}
|
|
37
|
+
|
|
38
|
+
// ── identity & copy ────────────────────────────────────────────────
|
|
39
|
+
const NS = "lastTurnDelete";
|
|
40
|
+
const REMOTE_NS = "lastTurnDelete";
|
|
41
|
+
const ROW_DELETED = "data-ltd-deleted";
|
|
42
|
+
const DIAG_VERSION = 1;
|
|
43
|
+
|
|
44
|
+
const zh = {
|
|
45
|
+
"delete.title": "删除本条消息及其全部回复",
|
|
46
|
+
"delete.confirmTitle": "删除这条消息及它的全部回复?",
|
|
47
|
+
"delete.confirmNote": "删除后不再进入后续模型上下文(原始日志保留,界面将变暗标记)。",
|
|
48
|
+
"delete.ok": "确认删除",
|
|
49
|
+
"delete.cancel": "取消",
|
|
50
|
+
"delete.busy": "模型正在回复,请等回复结束后再删除",
|
|
51
|
+
"delete.failed": "删除失败,请重试",
|
|
52
|
+
"delete.cardTitle": "删除这条失败的压缩记录?",
|
|
53
|
+
"delete.cardNote": "它没有成功压缩任何内容,也不影响模型上下文;删除后会从这里继续向前。"
|
|
54
|
+
};
|
|
55
|
+
const en = {
|
|
56
|
+
"delete.title": "Delete this message and all its replies",
|
|
57
|
+
"delete.confirmTitle": "Delete this message and all its replies?",
|
|
58
|
+
"delete.confirmNote": "It will stop entering the model context of later requests (original log is kept; this turn will be dimmed).",
|
|
59
|
+
"delete.ok": "Delete",
|
|
60
|
+
"delete.cancel": "Cancel",
|
|
61
|
+
"delete.busy": "The model is still replying — wait for it to finish before deleting",
|
|
62
|
+
"delete.failed": "Deletion failed, please retry",
|
|
63
|
+
"delete.cardTitle": "Delete this failed compaction entry?",
|
|
64
|
+
"delete.cardNote": "It did not compact anything and never entered the model context; deleting lets the chain continue past it."
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ── slim diagnostics ───────────────────────────────────────────────
|
|
68
|
+
const diag = {
|
|
69
|
+
applied: false,
|
|
70
|
+
applyError: null,
|
|
71
|
+
attaches: 0,
|
|
72
|
+
deletes: 0,
|
|
73
|
+
log: []
|
|
74
|
+
};
|
|
75
|
+
function diagPush(...args) {
|
|
76
|
+
diag.log.push([new Date().toISOString().slice(11, 19), ...args]);
|
|
77
|
+
if (diag.log.length > 40) diag.log.shift();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── DOM helpers over the transcript's stable anchors ───────────────
|
|
81
|
+
function uuidOf(value) {
|
|
82
|
+
const match = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.exec(String(value ?? ""));
|
|
83
|
+
return match === null ? null : match[0];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function rowKind(row) {
|
|
87
|
+
return row.getAttribute("data-chat-flow-kind");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function rowMessageId(row) {
|
|
91
|
+
return uuidOf(row.getAttribute("data-chat-anchor-key"));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function visibleRows(flow) {
|
|
95
|
+
const rows = [];
|
|
96
|
+
for (const row of flow.querySelectorAll("[data-chat-flow-key]")) {
|
|
97
|
+
if (row.hasAttribute("hidden")) continue;
|
|
98
|
+
rows.push(row);
|
|
99
|
+
}
|
|
100
|
+
return rows;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Mount the same trash icon the pending-message queue uses
|
|
105
|
+
* (IconTrashOutline16 from dsh-client-ui-primitives) into `host`, so the
|
|
106
|
+
* glyph is identical with the queue's delete button. Emergency fallback
|
|
107
|
+
* only draws a trash bin, never a ✕.
|
|
108
|
+
*/
|
|
109
|
+
function mountTrashIcon(host, fallbackCloneSource) {
|
|
110
|
+
if (Primitives !== null && createRootHost !== null && typeof Primitives.IconTrashOutline16 === "function") {
|
|
111
|
+
const holder = document.createElement("span");
|
|
112
|
+
holder.style.display = "inline-flex";
|
|
113
|
+
holder.style.alignItems = "center";
|
|
114
|
+
host.appendChild(holder);
|
|
115
|
+
const root = createRootHost(holder);
|
|
116
|
+
root.render(react.createElement(Primitives.IconTrashOutline16, { size: 16 }));
|
|
117
|
+
return root;
|
|
118
|
+
}
|
|
119
|
+
const sourceSvg = fallbackCloneSource === null ? null : fallbackCloneSource.querySelector("svg");
|
|
120
|
+
let icon;
|
|
121
|
+
if (sourceSvg !== null) {
|
|
122
|
+
icon = sourceSvg.cloneNode(true);
|
|
123
|
+
while (icon.firstChild !== null) icon.removeChild(icon.firstChild);
|
|
124
|
+
} else {
|
|
125
|
+
icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
126
|
+
icon.setAttribute("width", "16");
|
|
127
|
+
icon.setAttribute("height", "16");
|
|
128
|
+
icon.setAttribute("viewBox", "0 0 16 16");
|
|
129
|
+
icon.setAttribute("fill", "none");
|
|
130
|
+
icon.setAttribute("aria-hidden", "true");
|
|
131
|
+
}
|
|
132
|
+
const stroke = icon.getAttribute("stroke") ?? "currentColor";
|
|
133
|
+
const strokeWidth = icon.getAttribute("stroke-width");
|
|
134
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
135
|
+
path.setAttribute("d", "M4 6v6a1.5 1.5 0 0 0 1.5 1.5h5A1.5 1.5 0 0 0 12 12V6M6.5 6v5M9.5 6v5M5.5 4.5h5M8 2.75v1.75");
|
|
136
|
+
path.setAttribute("stroke", stroke);
|
|
137
|
+
path.setAttribute("stroke-linecap", "round");
|
|
138
|
+
path.setAttribute("stroke-linejoin", "round");
|
|
139
|
+
if (strokeWidth !== null) path.setAttribute("stroke-width", strokeWidth);
|
|
140
|
+
if (!icon.hasAttribute("fill")) icon.setAttribute("fill", "none");
|
|
141
|
+
icon.appendChild(path);
|
|
142
|
+
host.appendChild(icon);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The copy button of a message row (identified by its localized aria-label). */
|
|
147
|
+
function findCopyButton(row) {
|
|
148
|
+
for (const button of row.querySelectorAll("button")) {
|
|
149
|
+
const label = button.getAttribute("aria-label") ?? "";
|
|
150
|
+
if (/copy|复制/i.test(label)) return button;
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Button shell that reuses the native action button's class (look & hover). */
|
|
156
|
+
function nativeButtonClone(template) {
|
|
157
|
+
const button = document.createElement("button");
|
|
158
|
+
button.type = "button";
|
|
159
|
+
if (template !== null && typeof template.className === "string") button.className = template.className;
|
|
160
|
+
return button;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Unwrap the host business envelope from an rpc response. */
|
|
164
|
+
function rpcResultOf(raw) {
|
|
165
|
+
// connection.rpc.call resolves { ok, value | error }; our host
|
|
166
|
+
// methods return a business { ok, value | error } envelope as value.
|
|
167
|
+
if (raw === null || typeof raw !== "object") return { ok: false, error: { code: "internal" } };
|
|
168
|
+
if (raw.ok !== true) return raw;
|
|
169
|
+
return raw.value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* One overlay per open session: dims soft-deleted spans and owns the
|
|
174
|
+
* single trash button + confirm bubble of the newest deletable item.
|
|
175
|
+
*/
|
|
176
|
+
class Overlay {
|
|
177
|
+
constructor(sessionId, propsRef) {
|
|
178
|
+
this.sessionId = sessionId;
|
|
179
|
+
this.propsRef = propsRef;
|
|
180
|
+
this.deletedIds = new Set();
|
|
181
|
+
this.deletedCompacts = [];
|
|
182
|
+
this.hostTailKind = null; // "message" | "compact" | null
|
|
183
|
+
this.hostTailId = null;
|
|
184
|
+
this.hostRunning = true; // conservative until the first answer
|
|
185
|
+
this.tailFetchedAt = 0;
|
|
186
|
+
this.tailPending = false;
|
|
187
|
+
this.button = null;
|
|
188
|
+
this.buttonHost = null;
|
|
189
|
+
this.popover = null;
|
|
190
|
+
this.glyphRoot = null;
|
|
191
|
+
this.disposed = false;
|
|
192
|
+
this.rafPending = false;
|
|
193
|
+
this.observer = null;
|
|
194
|
+
this.interval = null;
|
|
195
|
+
this.lastSync = null;
|
|
196
|
+
this.lastError = null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
start() {
|
|
200
|
+
this.observer = new MutationObserver(() => this.schedule());
|
|
201
|
+
this.observer.observe(document.body, { childList: true, subtree: true, attributes: true });
|
|
202
|
+
this.interval = setInterval(() => this.schedule(), 1600);
|
|
203
|
+
this.seedDeleted().catch((error) => this.note(error));
|
|
204
|
+
this.schedule();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
note(error) {
|
|
208
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
209
|
+
diagPush("error", this.sessionId, this.lastError);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
dispose() {
|
|
213
|
+
this.disposed = true;
|
|
214
|
+
if (this.interval !== null) clearInterval(this.interval);
|
|
215
|
+
if (this.observer !== null) this.observer.disconnect();
|
|
216
|
+
this.detachButton();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Seed previously deleted messages / failed-compact cards for dimming. */
|
|
220
|
+
async seedDeleted() {
|
|
221
|
+
const props = this.propsRef.current;
|
|
222
|
+
if (props.deletedList === void 0) return;
|
|
223
|
+
const result = rpcResultOf(await props.deletedList());
|
|
224
|
+
if (result.ok !== true || typeof result.value !== "object" || result.value === null) return;
|
|
225
|
+
if (Array.isArray(result.value.items)) {
|
|
226
|
+
for (const item of result.value.items) {
|
|
227
|
+
if (item !== null && typeof item === "object" && typeof item.messageId === "string") {
|
|
228
|
+
this.deletedIds.add(item.messageId);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (Array.isArray(result.value.compactCommandIds)) {
|
|
233
|
+
this.deletedCompacts = result.value.compactCommandIds.filter((id) => typeof id === "string");
|
|
234
|
+
}
|
|
235
|
+
this.refreshTail();
|
|
236
|
+
this.schedule();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Ask the host which item is the deletable tail and whether the agent
|
|
241
|
+
* is running. The host folds compaction boundaries, prior deletions
|
|
242
|
+
* and streaming into this single answer.
|
|
243
|
+
*/
|
|
244
|
+
refreshTail() {
|
|
245
|
+
if (this.disposed || this.tailPending) return;
|
|
246
|
+
if (Date.now() - this.tailFetchedAt < 500) return;
|
|
247
|
+
const props = this.propsRef.current;
|
|
248
|
+
if (props.tail === void 0) return;
|
|
249
|
+
this.tailPending = true;
|
|
250
|
+
const settle = (value) => {
|
|
251
|
+
if (value === null || value.kind === null) {
|
|
252
|
+
this.hostTailKind = null;
|
|
253
|
+
this.hostTailId = null;
|
|
254
|
+
} else if (value.kind === "compact") {
|
|
255
|
+
this.hostTailKind = "compact";
|
|
256
|
+
this.hostTailId = value.commandId ?? null;
|
|
257
|
+
} else {
|
|
258
|
+
this.hostTailKind = "message";
|
|
259
|
+
this.hostTailId = value.messageId ?? null;
|
|
260
|
+
}
|
|
261
|
+
this.hostRunning = value === null || value.running === true;
|
|
262
|
+
this.tailFetchedAt = Date.now();
|
|
263
|
+
this.schedule();
|
|
264
|
+
};
|
|
265
|
+
Promise.resolve(props.tail())
|
|
266
|
+
.then((raw) => {
|
|
267
|
+
const result = rpcResultOf(raw);
|
|
268
|
+
if (result.ok === true) settle(result.value ?? null);
|
|
269
|
+
else this.note(result?.error ?? new Error("tail failed"));
|
|
270
|
+
})
|
|
271
|
+
.catch((error) => this.note(error))
|
|
272
|
+
.finally(() => {
|
|
273
|
+
this.tailPending = false;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
schedule() {
|
|
278
|
+
if (this.disposed || this.rafPending) return;
|
|
279
|
+
this.rafPending = true;
|
|
280
|
+
requestAnimationFrame(() => {
|
|
281
|
+
this.rafPending = false;
|
|
282
|
+
if (this.disposed) return;
|
|
283
|
+
try {
|
|
284
|
+
this.sync();
|
|
285
|
+
} catch (error) {
|
|
286
|
+
this.note(error);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
sync() {
|
|
292
|
+
const flow = document.querySelector("[data-chat-flow]");
|
|
293
|
+
if (flow === null) {
|
|
294
|
+
this.lastSync = { t: Date.now(), flow: false };
|
|
295
|
+
this.detachButton();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const rows = visibleRows(flow);
|
|
299
|
+
this.applyDimmed(rows);
|
|
300
|
+
|
|
301
|
+
// Local candidates (newest first, skipping soft-deleted rows): a
|
|
302
|
+
// user/steering row and a manual card row. Cards are ordinary rows,
|
|
303
|
+
// never a wall — the host decides what is truly deletable.
|
|
304
|
+
let candidateMsg = null;
|
|
305
|
+
let candidateCard = null;
|
|
306
|
+
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
|
307
|
+
const row = rows[index];
|
|
308
|
+
if (row.hasAttribute(ROW_DELETED)) continue;
|
|
309
|
+
const kind = rowKind(row);
|
|
310
|
+
if (candidateMsg === null && (kind === "user" || kind === "steering")) {
|
|
311
|
+
candidateMsg = { row, kind: "message", messageId: rowMessageId(row) };
|
|
312
|
+
} else if (candidateCard === null && (kind === "manual-compaction" || kind === "command")) {
|
|
313
|
+
candidateCard = { row, kind: "compact" };
|
|
314
|
+
}
|
|
315
|
+
if (candidateMsg !== null && candidateCard !== null) break;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Keep the host answer fresh: while running, before the first
|
|
319
|
+
// answer, or when the cached id went stale we re-ask (throttled).
|
|
320
|
+
const stale = Date.now() - this.tailFetchedAt > 1500;
|
|
321
|
+
if (this.hostTailId === null || this.hostRunning || stale) this.refreshTail();
|
|
322
|
+
|
|
323
|
+
let target = null;
|
|
324
|
+
if (this.hostRunning) {
|
|
325
|
+
target = null; // replying — keep hidden until the turn fully ends
|
|
326
|
+
} else if (this.hostTailKind === "message"
|
|
327
|
+
&& candidateMsg !== null
|
|
328
|
+
&& candidateMsg.messageId !== null
|
|
329
|
+
&& candidateMsg.messageId === this.hostTailId) {
|
|
330
|
+
target = candidateMsg;
|
|
331
|
+
} else if (this.hostTailKind === "compact" && candidateCard !== null && !stale) {
|
|
332
|
+
target = candidateCard;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
this.lastSync = {
|
|
336
|
+
t: Date.now(),
|
|
337
|
+
flow: true,
|
|
338
|
+
rows: rows.length,
|
|
339
|
+
deletedCount: this.deletedIds.size,
|
|
340
|
+
deletedCards: this.deletedCompacts.length,
|
|
341
|
+
candidateMsgId: candidateMsg === null ? null : candidateMsg.messageId,
|
|
342
|
+
hostTailKind: this.hostTailKind,
|
|
343
|
+
hostTailId: this.hostTailId,
|
|
344
|
+
hostRunning: this.hostRunning,
|
|
345
|
+
targetKind: target === null ? null : target.kind
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
if (target === null) {
|
|
349
|
+
this.detachButton();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
this.attachButton(target);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Dim deleted user turns and forgotten failed-compact cards in place. */
|
|
356
|
+
applyDimmed(rows) {
|
|
357
|
+
let dimmed = false;
|
|
358
|
+
for (const row of rows) {
|
|
359
|
+
const kind = rowKind(row);
|
|
360
|
+
if (kind === "user" || kind === "steering") {
|
|
361
|
+
const id = rowMessageId(row);
|
|
362
|
+
dimmed = id !== null && this.deletedIds.has(id);
|
|
363
|
+
} else if (kind === "compaction" || kind === "manual-compaction") {
|
|
364
|
+
dimmed = false;
|
|
365
|
+
}
|
|
366
|
+
row.style.opacity = dimmed ? "0.38" : "";
|
|
367
|
+
if (dimmed) row.setAttribute(ROW_DELETED, "");
|
|
368
|
+
else row.removeAttribute(ROW_DELETED);
|
|
369
|
+
}
|
|
370
|
+
// Forgotten failed-compact cards are deleted newest-first, so the
|
|
371
|
+
// newest N card rows correspond to the stored command ids.
|
|
372
|
+
if (this.deletedCompacts.length > 0) {
|
|
373
|
+
const cardRows = rows.filter((row) => {
|
|
374
|
+
const kind = rowKind(row);
|
|
375
|
+
return kind === "manual-compaction" || kind === "command";
|
|
376
|
+
});
|
|
377
|
+
const mark = Math.min(this.deletedCompacts.length, cardRows.length);
|
|
378
|
+
for (let index = cardRows.length - mark; index < cardRows.length; index += 1) {
|
|
379
|
+
cardRows[index].style.opacity = "0.38";
|
|
380
|
+
cardRows[index].setAttribute(ROW_DELETED, "");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
closeConfirm() {
|
|
386
|
+
if (this.popover === null) return;
|
|
387
|
+
const cleanup = this.popover.__ltdCleanup;
|
|
388
|
+
if (typeof cleanup === "function") cleanup();
|
|
389
|
+
if (this.popover.parentElement !== null) this.popover.parentElement.removeChild(this.popover);
|
|
390
|
+
this.popover = null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
detachButton() {
|
|
394
|
+
this.closeConfirm();
|
|
395
|
+
if (this.glyphRoot !== null) {
|
|
396
|
+
try {
|
|
397
|
+
this.glyphRoot.unmount();
|
|
398
|
+
} catch {}
|
|
399
|
+
this.glyphRoot = null;
|
|
400
|
+
}
|
|
401
|
+
if (this.button !== null && this.button.parentElement !== null) {
|
|
402
|
+
this.button.parentElement.removeChild(this.button);
|
|
403
|
+
}
|
|
404
|
+
this.button = null;
|
|
405
|
+
this.buttonHost = null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
attachButton(target) {
|
|
409
|
+
if (this.buttonHost === target.row && this.button !== null) return;
|
|
410
|
+
this.detachButton();
|
|
411
|
+
const props = this.propsRef.current;
|
|
412
|
+
const t = props.t ?? ((key) => zh[key] ?? key);
|
|
413
|
+
const row = target.row;
|
|
414
|
+
const copy = findCopyButton(row);
|
|
415
|
+
const strip = copy === null ? null : copy.parentElement;
|
|
416
|
+
|
|
417
|
+
const button = nativeButtonClone(copy);
|
|
418
|
+
this.glyphRoot = mountTrashIcon(button, copy);
|
|
419
|
+
button.type = "button";
|
|
420
|
+
button.setAttribute("aria-label", t(target.kind === "compact" ? "delete.cardTitle" : "delete.title"));
|
|
421
|
+
button.title = t(target.kind === "compact" ? "delete.cardTitle" : "delete.title");
|
|
422
|
+
button.addEventListener("click", () => {
|
|
423
|
+
if (this.disposed) return;
|
|
424
|
+
this.openConfirm(target, button, t);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
if (strip !== null && copy !== null) {
|
|
428
|
+
strip.insertBefore(button, copy.nextSibling); // join the native action strip
|
|
429
|
+
} else {
|
|
430
|
+
// Rare fallback: a row without the native action strip.
|
|
431
|
+
row.style.position = "relative";
|
|
432
|
+
Object.assign(button.style, {
|
|
433
|
+
position: "absolute", right: "8px", bottom: "8px", zIndex: "6",
|
|
434
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
435
|
+
width: "22px", height: "22px", padding: "0", border: "0", borderRadius: "6px",
|
|
436
|
+
background: "transparent", color: "inherit", cursor: "pointer"
|
|
437
|
+
});
|
|
438
|
+
row.appendChild(button);
|
|
439
|
+
}
|
|
440
|
+
this.button = button;
|
|
441
|
+
this.buttonHost = row;
|
|
442
|
+
diag.attaches += 1;
|
|
443
|
+
diagPush("attach", this.sessionId, target.kind, target.messageId ?? null);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Open the confirm bubble anchored to the trash button. All styles are
|
|
448
|
+
* inline on the created nodes — injected <style> blocks are managed
|
|
449
|
+
* (and dropped) by the host module system, so they cannot be relied on.
|
|
450
|
+
*/
|
|
451
|
+
openConfirm(target, anchor, t) {
|
|
452
|
+
this.closeConfirm();
|
|
453
|
+
const card = target.kind === "compact";
|
|
454
|
+
const title = t(card ? "delete.cardTitle" : "delete.confirmTitle");
|
|
455
|
+
const note = t(card ? "delete.cardNote" : "delete.confirmNote");
|
|
456
|
+
|
|
457
|
+
const popover = document.createElement("div");
|
|
458
|
+
popover.setAttribute("role", "dialog");
|
|
459
|
+
popover.setAttribute("aria-label", title);
|
|
460
|
+
Object.assign(popover.style, {
|
|
461
|
+
position: "fixed", zIndex: "9999", width: "260px", padding: "12px",
|
|
462
|
+
borderRadius: "10px", background: "#202429", color: "#e8eaed",
|
|
463
|
+
border: "1px solid rgba(128,134,144,0.4)",
|
|
464
|
+
boxShadow: "0 8px 28px rgba(0,0,0,0.45)", fontSize: "13px", lineHeight: "1.5"
|
|
465
|
+
});
|
|
466
|
+
const titleEl = document.createElement("p");
|
|
467
|
+
titleEl.textContent = title;
|
|
468
|
+
Object.assign(titleEl.style, { margin: "0 0 8px", fontWeight: "600" });
|
|
469
|
+
const noteEl = document.createElement("p");
|
|
470
|
+
noteEl.textContent = note;
|
|
471
|
+
Object.assign(noteEl.style, { margin: "0 0 12px", opacity: "0.75", fontSize: "12px" });
|
|
472
|
+
const errorEl = document.createElement("p");
|
|
473
|
+
errorEl.setAttribute("role", "status");
|
|
474
|
+
Object.assign(errorEl.style, { margin: "0 0 8px", color: "#ff8a80", display: "none" });
|
|
475
|
+
const buttons = document.createElement("div");
|
|
476
|
+
Object.assign(buttons.style, { display: "flex", justifyContent: "flex-end", gap: "8px" });
|
|
477
|
+
const cancel = document.createElement("button");
|
|
478
|
+
cancel.type = "button";
|
|
479
|
+
cancel.textContent = t("delete.cancel");
|
|
480
|
+
Object.assign(cancel.style, {
|
|
481
|
+
border: "0", borderRadius: "8px", padding: "6px 14px", fontSize: "13px",
|
|
482
|
+
cursor: "pointer", background: "rgba(128,134,144,0.22)", color: "inherit"
|
|
483
|
+
});
|
|
484
|
+
const ok = document.createElement("button");
|
|
485
|
+
ok.type = "button";
|
|
486
|
+
ok.textContent = t("delete.ok");
|
|
487
|
+
Object.assign(ok.style, {
|
|
488
|
+
border: "0", borderRadius: "8px", padding: "6px 14px", fontSize: "13px",
|
|
489
|
+
cursor: "pointer", background: "#c93838", color: "#fff"
|
|
490
|
+
});
|
|
491
|
+
const setError = (message) => {
|
|
492
|
+
if (message === null) errorEl.style.display = "none";
|
|
493
|
+
else {
|
|
494
|
+
errorEl.textContent = message;
|
|
495
|
+
errorEl.style.display = "block";
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
const resetOk = () => {
|
|
499
|
+
ok.disabled = false;
|
|
500
|
+
ok.style.opacity = "";
|
|
501
|
+
};
|
|
502
|
+
cancel.addEventListener("click", () => this.closeConfirm());
|
|
503
|
+
ok.addEventListener("click", () => {
|
|
504
|
+
ok.disabled = true;
|
|
505
|
+
ok.style.opacity = "0.5";
|
|
506
|
+
this.executeDelete(target, resetOk, t, setError).catch(() => {
|
|
507
|
+
resetOk();
|
|
508
|
+
setError(t("delete.failed"));
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
buttons.append(cancel, ok);
|
|
512
|
+
popover.append(titleEl, noteEl, errorEl, buttons);
|
|
513
|
+
document.body.appendChild(popover);
|
|
514
|
+
this.popover = popover;
|
|
515
|
+
|
|
516
|
+
const place = () => {
|
|
517
|
+
const rect = anchor.getBoundingClientRect();
|
|
518
|
+
popover.style.visibility = "hidden";
|
|
519
|
+
const width = popover.offsetWidth;
|
|
520
|
+
const height = popover.offsetHeight;
|
|
521
|
+
const left = Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8);
|
|
522
|
+
let top = rect.bottom + 6;
|
|
523
|
+
if (top + height > window.innerHeight - 8) top = Math.max(8, rect.top - height - 6);
|
|
524
|
+
popover.style.left = `${Math.round(left)}px`;
|
|
525
|
+
popover.style.top = `${Math.round(top)}px`;
|
|
526
|
+
popover.style.visibility = "";
|
|
527
|
+
};
|
|
528
|
+
place();
|
|
529
|
+
|
|
530
|
+
const onPointerDown = (event) => {
|
|
531
|
+
if (this.popover !== popover) return;
|
|
532
|
+
if (popover.contains(event.target)) return;
|
|
533
|
+
this.closeConfirm();
|
|
534
|
+
};
|
|
535
|
+
const onKey = (event) => {
|
|
536
|
+
if (this.popover !== popover) return;
|
|
537
|
+
if (event.key === "Escape") this.closeConfirm();
|
|
538
|
+
};
|
|
539
|
+
const onScroll = () => this.closeConfirm();
|
|
540
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
541
|
+
document.addEventListener("keydown", onKey, true);
|
|
542
|
+
window.addEventListener("scroll", onScroll, true);
|
|
543
|
+
window.addEventListener("resize", place);
|
|
544
|
+
popover.__ltdCleanup = () => {
|
|
545
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
546
|
+
document.removeEventListener("keydown", onKey, true);
|
|
547
|
+
window.removeEventListener("scroll", onScroll, true);
|
|
548
|
+
window.removeEventListener("resize", place);
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async executeDelete(target, resetOk, t, setError) {
|
|
553
|
+
if (this.disposed) return;
|
|
554
|
+
const props = this.propsRef.current;
|
|
555
|
+
const card = target.kind === "compact";
|
|
556
|
+
if (card && props.deleteCompact === void 0) return;
|
|
557
|
+
if (!card && (props.deleteTurn === void 0 || props.sessionId !== this.sessionId)) return;
|
|
558
|
+
|
|
559
|
+
const result = rpcResultOf(card
|
|
560
|
+
? await props.deleteCompact()
|
|
561
|
+
: await props.deleteTurn(target.messageId));
|
|
562
|
+
diag.deletes += 1;
|
|
563
|
+
diagPush("delete", this.sessionId, card ? "compact" : "message",
|
|
564
|
+
result.ok === true ? "ok" : (result?.error?.code ?? "error"));
|
|
565
|
+
|
|
566
|
+
if (result.ok === true) {
|
|
567
|
+
if (card) {
|
|
568
|
+
const commandId = result.value?.commandId;
|
|
569
|
+
if (typeof commandId === "string" && !this.deletedCompacts.includes(commandId)) {
|
|
570
|
+
this.deletedCompacts = [...this.deletedCompacts, commandId];
|
|
571
|
+
}
|
|
572
|
+
} else {
|
|
573
|
+
this.deletedIds.add(target.messageId);
|
|
574
|
+
}
|
|
575
|
+
this.closeConfirm();
|
|
576
|
+
this.hostTailId = null;
|
|
577
|
+
this.tailFetchedAt = 0;
|
|
578
|
+
this.refreshTail();
|
|
579
|
+
this.schedule();
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const code = result.error?.code;
|
|
584
|
+
if (code === "stale" || code === "no-target") {
|
|
585
|
+
// The tail moved or is already gone: resync and re-anchor.
|
|
586
|
+
this.closeConfirm();
|
|
587
|
+
this.hostTailId = null;
|
|
588
|
+
this.tailFetchedAt = 0;
|
|
589
|
+
await this.seedDeleted();
|
|
590
|
+
this.refreshTail();
|
|
591
|
+
this.schedule();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
resetOk();
|
|
595
|
+
setError(code === "busy" ? t("delete.busy") : t("delete.failed"));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ── one overlay per session, refcounted by mounted action entries ─────
|
|
600
|
+
const managers = new Map();
|
|
601
|
+
|
|
602
|
+
function registerManager(sessionId, propsRef) {
|
|
603
|
+
let record = managers.get(sessionId);
|
|
604
|
+
if (record === void 0) {
|
|
605
|
+
const overlay = new Overlay(sessionId, propsRef);
|
|
606
|
+
record = { overlay, refs: 0 };
|
|
607
|
+
managers.set(sessionId, record);
|
|
608
|
+
overlay.start();
|
|
609
|
+
} else {
|
|
610
|
+
record.overlay.propsRef = propsRef;
|
|
611
|
+
}
|
|
612
|
+
record.refs += 1;
|
|
613
|
+
return () => {
|
|
614
|
+
record.refs -= 1;
|
|
615
|
+
if (record.refs > 0) return;
|
|
616
|
+
managers.delete(sessionId);
|
|
617
|
+
record.overlay.dispose();
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Session anchor: hosted on every completed turn's assistant action row
|
|
623
|
+
* (same slot the shipped Like/Dislike uses). Renders nothing itself; its
|
|
624
|
+
* lifecycle drives the singleton overlay for the session.
|
|
625
|
+
*/
|
|
626
|
+
function LastTurnDeleteCue(props) {
|
|
627
|
+
const sessionId = props.sessionId;
|
|
628
|
+
const propsRef = react.useRef(props);
|
|
629
|
+
propsRef.current = props;
|
|
630
|
+
react.useEffect(() => {
|
|
631
|
+
if (typeof sessionId !== "string") return void 0;
|
|
632
|
+
return registerManager(sessionId, propsRef);
|
|
633
|
+
}, [sessionId]);
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ── client plugin body ──────────────────────────────────────────────
|
|
638
|
+
/** Client services this plugin needs. */
|
|
639
|
+
const inject = ["slots", "locale", "connection"];
|
|
640
|
+
|
|
641
|
+
function apply(ctx) {
|
|
642
|
+
diag.applied = true;
|
|
643
|
+
try {
|
|
644
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-last-turn-delete: dictionaries");
|
|
645
|
+
ctx.slots.inject("conversation.chat.assistant-actions", () => ctx.slots.register({
|
|
646
|
+
name: "conversation.chat.assistant-actions",
|
|
647
|
+
id: "last-turn-delete",
|
|
648
|
+
order: 130,
|
|
649
|
+
locale: NS,
|
|
650
|
+
inject: (sessionId) => ({
|
|
651
|
+
sessionId,
|
|
652
|
+
deleteTurn: async (messageId) => ctx.connection.rpc.call("/api", `${REMOTE_NS}/delete`, {
|
|
653
|
+
args: { request: { sessionId, messageId } }
|
|
654
|
+
}),
|
|
655
|
+
deleteCompact: async () => ctx.connection.rpc.call("/api", `${REMOTE_NS}/deleteCompact`, {
|
|
656
|
+
args: { request: { sessionId } }
|
|
657
|
+
}),
|
|
658
|
+
deletedList: async () => ctx.connection.rpc.call("/api", `${REMOTE_NS}/deleted`, {
|
|
659
|
+
args: { request: { sessionId } }
|
|
660
|
+
}),
|
|
661
|
+
tail: async () => ctx.connection.rpc.call("/api", `${REMOTE_NS}/tail`, {
|
|
662
|
+
args: { request: { sessionId } }
|
|
663
|
+
})
|
|
664
|
+
})
|
|
665
|
+
}, LastTurnDeleteCue));
|
|
666
|
+
} catch (error) {
|
|
667
|
+
diag.applyError = error instanceof Error ? error.message : String(error);
|
|
668
|
+
diagPush("apply error", diag.applyError);
|
|
669
|
+
throw error;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
try {
|
|
673
|
+
globalThis.__dshLastTurnDelete = {
|
|
674
|
+
version: DIAG_VERSION,
|
|
675
|
+
diag,
|
|
676
|
+
refresh: () => {
|
|
677
|
+
for (const record of managers.values()) record.overlay.schedule();
|
|
678
|
+
},
|
|
679
|
+
diagnose: () => ({
|
|
680
|
+
applied: diag.applied,
|
|
681
|
+
applyError: diag.applyError,
|
|
682
|
+
attaches: diag.attaches,
|
|
683
|
+
deletes: diag.deletes,
|
|
684
|
+
overlays: [...managers.values()].map((record) => ({
|
|
685
|
+
sessionId: record.overlay.sessionId,
|
|
686
|
+
deletedCount: record.overlay.deletedIds.size,
|
|
687
|
+
deletedCards: record.overlay.deletedCompacts.length,
|
|
688
|
+
lastError: record.overlay.lastError,
|
|
689
|
+
lastSync: record.overlay.lastSync
|
|
690
|
+
})),
|
|
691
|
+
flowPresent: document.querySelector("[data-chat-flow]") !== null,
|
|
692
|
+
logTail: diag.log.slice(-30)
|
|
693
|
+
})
|
|
694
|
+
};
|
|
695
|
+
} catch {
|
|
696
|
+
// diagnostics are best-effort
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
exports.apply = apply;
|
|
701
|
+
exports.inject = inject;
|
|
702
|
+
return module.exports;
|
|
703
|
+
}
|
|
704
|
+
});
|