dsh-as-aistudio 0.4.6 → 0.4.8
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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/client.js +408 -22
- package/src/components.js +1 -1
- package/src/vendor/host/dsh-delete-turn.js +88 -35
- package/src/vendor/host/dsh-delete-turn.logic.js +72 -1
- package/src/vendor/host/dsh-edit-turn.js +9 -2
- package/src/vendor/host/dsh-edit-turn.patch.js +1 -1
- package/src/vendor/host/dsh-rerun-turn.js +172 -24
- package/src/vendor/versions.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
## 中文
|
|
6
6
|
|
|
7
|
+
**0.4.8** —— 跟进内嵌组件:delete 0.2.0、rerun 0.2.7。
|
|
8
|
+
|
|
9
|
+
- **dsh-delete-turn 0.2.0**:多选删除;载体写上 `op`/`turn`,整轮删除额外写 `deletedTurn`/`deleteIndex`。
|
|
10
|
+
- **dsh-rerun-turn 0.2.7**:遮蔽窗口按表面节点、重放按槽位顺序、生成期间冻结旧轮渲染(不再闪回/空白/丢提问)。
|
|
11
|
+
- 内置版本记录同步更新;`vendor --check` 干净,115/115 全绿。
|
|
12
|
+
|
|
13
|
+
**0.4.7** —— 打开着的会话不再重新解码整份日志(跟进 `dsh-edit-turn` 0.5.13)。
|
|
14
|
+
|
|
15
|
+
- **组件跟进**:`dsh-edit-turn` **0.5.13**、`dsh-rerun-turn` 0.2.6、`dsh-delete-turn` 0.1.15、`dsh-markdown-bubble` 0.1.5。
|
|
16
|
+
|
|
17
|
+
- 单独计时后:插件的全部计算只有几十毫秒,**读日志 + 解压 1,408 ms** 才是瓶颈。现在宿主已经打开的会话直接用内存快照,不再从磁盘重解一遍 —— 编辑笔的出现从"秒级"降到"几十毫秒级"。
|
|
18
|
+
|
|
7
19
|
**0.4.6** —— 复制已修好,临时诊断撤掉;`/state` 再快一档。
|
|
8
20
|
|
|
9
21
|
- **组件跟进**:`dsh-edit-turn` **0.5.12**、`dsh-rerun-turn` 0.2.6、`dsh-delete-turn` 0.1.15、`dsh-markdown-bubble` 0.1.5。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-as-aistudio",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "Google-AI-Studio-style turn controls for DeepSeek Harness, in one install: edit a prompt and re-run it, re-run a reply in place, delete a turn from the model context, and render sent prompts as Markdown. Self-contained: the plugin list shows this one row, and the four components are vendored inside it - installing the studio installs nothing else.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/client.js
CHANGED
|
@@ -43,7 +43,7 @@ window.__ModuleLoader__.load({
|
|
|
43
43
|
const RERUN_PREFIX = '/dsh-rerun-turn'
|
|
44
44
|
|
|
45
45
|
/** Keep in sync with package.json and lib/index.js. */
|
|
46
|
-
const PLUGIN_VERSION = '0.5.
|
|
46
|
+
const PLUGIN_VERSION = '0.5.13'
|
|
47
47
|
|
|
48
48
|
// --- copy -----------------------------------------------------------------
|
|
49
49
|
|
|
@@ -5527,7 +5527,7 @@ window.__ModuleLoader__.load({
|
|
|
5527
5527
|
const ROUTE_PREFIX = '/dsh-rerun-turn'
|
|
5528
5528
|
|
|
5529
5529
|
/** Keep in sync with package.json and lib/index.js. */
|
|
5530
|
-
const PLUGIN_VERSION = '0.2.
|
|
5530
|
+
const PLUGIN_VERSION = '0.2.7'
|
|
5531
5531
|
|
|
5532
5532
|
// --- copy -----------------------------------------------------------------
|
|
5533
5533
|
|
|
@@ -5846,6 +5846,9 @@ window.__ModuleLoader__.load({
|
|
|
5846
5846
|
// button still carries the previous instance's click handler) must not
|
|
5847
5847
|
// open a confirm panel on a controller nobody renders any more.
|
|
5848
5848
|
this.disposed = false
|
|
5849
|
+
// Rows cloned from the rerun turn so the old prompt stays on screen while
|
|
5850
|
+
// the host has already shadowed it (see freezeTurn).
|
|
5851
|
+
this.frozen = []
|
|
5849
5852
|
this.view = Object.freeze({
|
|
5850
5853
|
hidden: new Map(),
|
|
5851
5854
|
reruns: [],
|
|
@@ -5938,6 +5941,14 @@ window.__ModuleLoader__.load({
|
|
|
5938
5941
|
if (typeof reply.messageId === 'string' && reply.messageId !== '') repliesByMessage.set(reply.messageId, reply)
|
|
5939
5942
|
}
|
|
5940
5943
|
const rerunning = data.rerunning === true
|
|
5944
|
+
// Merge the optimistic hide from the click that started this rerun:
|
|
5945
|
+
// /state only reports a rerun's rows once it is COMPLETE, and the
|
|
5946
|
+
// retired prompt must stay hidden while the replay streams in.
|
|
5947
|
+
if (this.pendingHidden instanceof Map) {
|
|
5948
|
+
for (const [seq] of this.pendingHidden) if (!hidden.has(seq)) hidden.set(seq, null)
|
|
5949
|
+
if (!rerunning) this.pendingHidden = null
|
|
5950
|
+
}
|
|
5951
|
+
if (!rerunning) this.releaseFrozen()
|
|
5941
5952
|
const markerTurns = new Set(Array.isArray(data.markerTurns) ? data.markerTurns.filter((turn) => typeof turn === 'number') : [])
|
|
5942
5953
|
// Turns whose content a landed rerun fully retired: their
|
|
5943
5954
|
// process/tail strips hold nothing and must vanish with it.
|
|
@@ -6061,9 +6072,45 @@ window.__ModuleLoader__.load({
|
|
|
6061
6072
|
this.publish({ notice: null, noticeError: false, dismissedKey: key })
|
|
6062
6073
|
}
|
|
6063
6074
|
|
|
6075
|
+
/**
|
|
6076
|
+
* 冻结目标轮的渲染:宿主在 apply 的那一刻就把这一轮的表面节点遮蔽了,重放的提问
|
|
6077
|
+
* 要等下一次渲染才出现,中间那一帧会没有提问气泡。这里在点击时把这一轮的行
|
|
6078
|
+
* 克隆一份,apply 成功后插回去,重跑结束再撤掉——只影响视觉,不碰日志。
|
|
6079
|
+
*/
|
|
6080
|
+
freezeTurn(turn) {
|
|
6081
|
+
this.releaseFrozen()
|
|
6082
|
+
if (!Number.isFinite(turn)) return
|
|
6083
|
+
for (const row of document.querySelectorAll('[data-chat-flow-key][data-chat-turn]')) {
|
|
6084
|
+
if (Number(row.getAttribute('data-chat-turn')) !== turn) continue
|
|
6085
|
+
const parent = row.parentElement
|
|
6086
|
+
if (parent === null) continue
|
|
6087
|
+
const clone = row.cloneNode(true)
|
|
6088
|
+
clone.setAttribute('data-dsrr-frozen', '1')
|
|
6089
|
+
// 别让平台/本插件把它当真实行:去掉流键,摘掉自己注入的动作条。
|
|
6090
|
+
clone.removeAttribute('data-chat-flow-key')
|
|
6091
|
+
for (const junk of clone.querySelectorAll('[data-dsrrh], [class*="_actions"]')) junk.remove()
|
|
6092
|
+
this.frozen.push({ clone, parent, before: row.nextSibling })
|
|
6093
|
+
}
|
|
6094
|
+
}
|
|
6095
|
+
|
|
6096
|
+
applyFrozen() {
|
|
6097
|
+
for (const item of this.frozen) {
|
|
6098
|
+
if (item.clone.parentElement !== null || !item.parent.isConnected) continue
|
|
6099
|
+
const anchor = item.before !== null && item.before.parentElement === item.parent ? item.before : null
|
|
6100
|
+
item.parent.insertBefore(item.clone, anchor)
|
|
6101
|
+
}
|
|
6102
|
+
}
|
|
6103
|
+
|
|
6104
|
+
releaseFrozen() {
|
|
6105
|
+
for (const item of this.frozen) if (item.clone.parentElement !== null) item.clone.remove()
|
|
6106
|
+
this.frozen = []
|
|
6107
|
+
}
|
|
6108
|
+
|
|
6064
6109
|
async send(entry) {
|
|
6065
6110
|
if (this.view.pending) return
|
|
6066
6111
|
this.publish({ pending: true, failure: null, notice: null, noticeError: false })
|
|
6112
|
+
const turn = Number.isFinite(entry.turn) ? entry.turn : entry.reply && Number.isFinite(entry.reply.turn) ? entry.reply.turn : null
|
|
6113
|
+
this.freezeTurn(turn)
|
|
6067
6114
|
try {
|
|
6068
6115
|
const res = await fetch(`${ROUTE_PREFIX}/apply`, {
|
|
6069
6116
|
method: 'POST',
|
|
@@ -6071,6 +6118,7 @@ window.__ModuleLoader__.load({
|
|
|
6071
6118
|
body: JSON.stringify({ sessionId: this.sessionId, seq: entry.seq, messageId: entry.messageId }),
|
|
6072
6119
|
})
|
|
6073
6120
|
const data = await res.json().catch(() => ({}))
|
|
6121
|
+
this.releaseFrozen()
|
|
6074
6122
|
if (!res.ok || !data.ok) {
|
|
6075
6123
|
const code = data && data.code ? String(data.code) : 'generic'
|
|
6076
6124
|
if (code === 'already-retired') {
|
|
@@ -6085,14 +6133,22 @@ window.__ModuleLoader__.load({
|
|
|
6085
6133
|
this.publish({ pending: false, failure: code, failureDetail: typeof data.error === 'string' && data.error !== '' ? data.error : null })
|
|
6086
6134
|
return
|
|
6087
6135
|
}
|
|
6088
|
-
//
|
|
6089
|
-
//
|
|
6090
|
-
//
|
|
6091
|
-
//
|
|
6136
|
+
// 宿主已经把这几行遮蔽了(shadow 是同步写下的),所以立刻隐藏它们:
|
|
6137
|
+
// 否则窗口盖掉 edit 载体的一瞬,它压住的原文会重新渲染出来(闪回)。
|
|
6138
|
+
// /state 在重跑完成前不会报告这些行(只报已完成),所以这份乐观集合要
|
|
6139
|
+
// 一直保留到 rerunning 结束,见 load() 里的合并。
|
|
6140
|
+
// 只藏“窗口之外的原文”(被 edit 载体替换掉的那些)。窗口自身的行由
|
|
6141
|
+
// 重跑中的分组逻辑处理,整段藏起来会把界面清空。
|
|
6142
|
+
const immediate = Array.isArray(data.retired) ? data.retired : []
|
|
6143
|
+
const pendingHidden = new Map()
|
|
6144
|
+
for (const seq of immediate) if (typeof seq === 'number') pendingHidden.set(seq, null)
|
|
6145
|
+
this.pendingHidden = pendingHidden
|
|
6146
|
+
this.applyFrozen()
|
|
6092
6147
|
this.publish({ pending: false, failure: null, rerunning: true })
|
|
6093
6148
|
this.ensurePolling()
|
|
6094
6149
|
this.load(true)
|
|
6095
6150
|
} catch {
|
|
6151
|
+
this.releaseFrozen()
|
|
6096
6152
|
this.publish({ pending: false, failure: 'generic' })
|
|
6097
6153
|
}
|
|
6098
6154
|
}
|
|
@@ -7084,6 +7140,14 @@ window.__ModuleLoader__.load({
|
|
|
7084
7140
|
'dialog.confirm': '删除',
|
|
7085
7141
|
'dialog.pending': '删除中…',
|
|
7086
7142
|
'dialog.retry': '重试',
|
|
7143
|
+
'dialog.title.batch': '删除选中的内容?',
|
|
7144
|
+
'dialog.desc.batch': '选中的消息、步骤与回复将从模型上下文中一并移除;没有被选中的内容保持原样。',
|
|
7145
|
+
'action.tooltip.select': '多选删除',
|
|
7146
|
+
'action.tooltip.selected': '已选中,再点一次取消',
|
|
7147
|
+
'select.count': '已选',
|
|
7148
|
+
'select.unit': '项',
|
|
7149
|
+
'select.delete': '删除选中',
|
|
7150
|
+
'select.cancel': '退出多选',
|
|
7087
7151
|
'config.ask': '删除时询问',
|
|
7088
7152
|
'config.hint.ask': '每次删除前都会弹出确认框。',
|
|
7089
7153
|
'config.hint.auto': '删除会直接执行,不再询问。',
|
|
@@ -7124,6 +7188,14 @@ window.__ModuleLoader__.load({
|
|
|
7124
7188
|
'dialog.confirm': 'Delete',
|
|
7125
7189
|
'dialog.pending': 'Deleting...',
|
|
7126
7190
|
'dialog.retry': 'Retry',
|
|
7191
|
+
'dialog.title.batch': 'Delete the selected items?',
|
|
7192
|
+
'dialog.desc.batch': 'The selected messages, steps and replies leave the model context together; everything not selected stays exactly as it is.',
|
|
7193
|
+
'action.tooltip.select': 'Select for multi-delete',
|
|
7194
|
+
'action.tooltip.selected': 'Selected - click again to unselect',
|
|
7195
|
+
'select.count': 'Selected',
|
|
7196
|
+
'select.unit': '',
|
|
7197
|
+
'select.delete': 'Delete selected',
|
|
7198
|
+
'select.cancel': 'Exit multi-select',
|
|
7127
7199
|
'config.ask': 'Ask before deleting',
|
|
7128
7200
|
'config.hint.ask': 'Show a confirmation dialog before every deletion.',
|
|
7129
7201
|
'config.hint.auto': 'Delete right away without asking.',
|
|
@@ -7155,6 +7227,10 @@ window.__ModuleLoader__.load({
|
|
|
7155
7227
|
'.dshdt-row{position:relative}',
|
|
7156
7228
|
'.dshdt-floating{position:absolute;top:2px;right:6px;z-index:2;opacity:0;transition:opacity .12s}',
|
|
7157
7229
|
'.dshdt-row:hover .dshdt-floating,.dshdt-floating:focus-within{opacity:1}',
|
|
7230
|
+
// While rows are being picked, every toggle has to be reachable without a hover pass over each row.
|
|
7231
|
+
// In the mode the hover host would only sit as an empty box over the
|
|
7232
|
+
// badge, so it steps out entirely instead of fading in.
|
|
7233
|
+
'.dshdt-select-on .dshdt-floating{display:none}',
|
|
7158
7234
|
'[data-variant="think"]{position:relative}',
|
|
7159
7235
|
'.dshdt-think-action{position:absolute;top:0;right:4px;opacity:0;transition:opacity .12s}',
|
|
7160
7236
|
'[data-variant="think"]:hover .dshdt-think-action,.dshdt-think-action:focus-within{opacity:1}',
|
|
@@ -7171,6 +7247,31 @@ window.__ModuleLoader__.load({
|
|
|
7171
7247
|
'.dshdt-turn:disabled{cursor:default;opacity:.4}',
|
|
7172
7248
|
'.dshdt-turn svg{width:15px;height:15px}',
|
|
7173
7249
|
'[data-dshdt-no-target="1"] .dshdt-action{display:none!important}',
|
|
7250
|
+
// In multi-select the toggle is a badge pinned to the row itself: the
|
|
7251
|
+
// action strip of a message row only exists while the pointer is on it,
|
|
7252
|
+
// so a toggle parked inside that strip is invisible exactly when the
|
|
7253
|
+
// mode needs it.
|
|
7254
|
+
'.dshdt-check{width:22px;height:22px;padding:0;display:none;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.35));border-radius:999px;background:var(--dsw-alias-bg-elevated,#fff);color:var(--dsw-alias-label-tertiary,#8a8f98);cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.14);transition:background-color .12s,color .12s}',
|
|
7255
|
+
'.dshdt-select-on{position:relative;opacity:1!important}',
|
|
7256
|
+
'.dshdt-select-on>.dshdt-check{display:inline-flex;position:absolute;top:6px;right:6px;z-index:5}',
|
|
7257
|
+
'.dshdt-check:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-button-primary-fill,#4d6bfe)}',
|
|
7258
|
+
'.dshdt-check:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4d6bfe);outline-offset:2px}',
|
|
7259
|
+
'.dshdt-check svg{width:13px;height:13px}',
|
|
7260
|
+
'.dshdt-checked .dshdt-check{color:var(--dsw-alias-button-primary-fill,#4d6bfe)}',
|
|
7261
|
+
'.dshdt-checked{outline:2px solid var(--dsw-alias-button-primary-fill,#4d6bfe);outline-offset:-2px;border-radius:14px}',
|
|
7262
|
+
'.dshdt-action-host[data-dshdt-badge-only="1"] .dshdt-action,.dshdt-action-host[data-dshdt-badge-only="1"] .dshdt-turn{display:none!important}',
|
|
7263
|
+
'.dshdt-action-host[data-dshdt-badge-only="1"]{pointer-events:none}',
|
|
7264
|
+
'.dshdt-action-host[data-dshdt-badge-only="1"]>.dshdt-check{pointer-events:auto}',
|
|
7265
|
+
'.dshdt-select-on .dshdt-action,.dshdt-select-on .dshdt-turn{display:none!important}',
|
|
7266
|
+
// The header entry sits among the Session utilities: the same 32px pill
|
|
7267
|
+
// the official utilities use, icon-only to stay compact.
|
|
7268
|
+
'.dshdt-header-select{display:inline-flex;align-items:center;justify-content:center;gap:4px;min-width:32px;height:32px;padding:0 8px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.24));border-radius:18px;background:transparent;color:var(--dsw-alias-label-primary,inherit);font-family:var(--dsw-font-family,inherit);font-size:13px;line-height:20px;cursor:pointer;transition:background-color .12s,color .12s,border-color .12s}',
|
|
7269
|
+
'.dshdt-header-select:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
|
|
7270
|
+
'.dshdt-header-select:disabled{color:var(--dsw-alias-label-dimmed,#9aa0aa);cursor:wait}',
|
|
7271
|
+
'.dshdt-header-select svg{flex:none}',
|
|
7272
|
+
'.dshdt-header-select-on{color:var(--dsw-alias-button-primary-fill,#4d6bfe);border-color:var(--dsw-alias-button-primary-fill,#4d6bfe);background:var(--dsw-alias-button-primary-ghost-fill,rgba(77,107,254,.08))}',
|
|
7273
|
+
'.dshdt-selectbar{position:fixed;left:50%;bottom:104px;transform:translateX(-50%);z-index:60;display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:999px;background:var(--dsw-alias-bg-elevated,#fff);color:var(--dsw-alias-label-primary,inherit);border:1px solid var(--dsw-alias-border-secondary,rgba(127,127,127,.2));box-shadow:0 8px 28px rgba(0,0,0,.18);font-size:13px}',
|
|
7274
|
+
'.dshdt-selectbar-count{min-width:56px;text-align:center;color:var(--dsw-alias-label-secondary,#5c6270)}',
|
|
7174
7275
|
'.dshdt-dialog-text{margin:0;color:var(--dsw-alias-label-primary,inherit);font-size:14px;line-height:22px}',
|
|
7175
7276
|
'.dshdt-dialog-note{margin:10px 0 0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
|
|
7176
7277
|
'.dshdt-dialog-warn{margin:10px 0 0;color:var(--dsw-alias-state-warn-primary,#a15c00);font-size:13px;line-height:20px}',
|
|
@@ -7225,6 +7326,16 @@ window.__ModuleLoader__.load({
|
|
|
7225
7326
|
).join('') +
|
|
7226
7327
|
'</svg>'
|
|
7227
7328
|
|
|
7329
|
+
// Multi-select glyph: a checkbox square with a tick.
|
|
7330
|
+
const CHECK_ICON_PATHS = ['M2.7 2.7h10.6v10.6H2.7z', 'M5.3 8.15l1.95 2 3.5-4.05']
|
|
7331
|
+
const CHECK_ICON_MARKUP =
|
|
7332
|
+
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
|
|
7333
|
+
CHECK_ICON_PATHS.map(
|
|
7334
|
+
(d) =>
|
|
7335
|
+
'<path d="' + d + '" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
7336
|
+
).join('') +
|
|
7337
|
+
'</svg>'
|
|
7338
|
+
|
|
7228
7339
|
function TrashIcon() {
|
|
7229
7340
|
return jsx('svg', {
|
|
7230
7341
|
width: 16,
|
|
@@ -7238,6 +7349,21 @@ window.__ModuleLoader__.load({
|
|
|
7238
7349
|
})
|
|
7239
7350
|
}
|
|
7240
7351
|
|
|
7352
|
+
// The multi-select mark: a checkbox square with a tick, so the header entry
|
|
7353
|
+
// reads as "select rows" rather than "delete".
|
|
7354
|
+
function CheckIcon() {
|
|
7355
|
+
return jsx('svg', {
|
|
7356
|
+
width: 16,
|
|
7357
|
+
height: 16,
|
|
7358
|
+
viewBox: '0 0 16 16',
|
|
7359
|
+
fill: 'none',
|
|
7360
|
+
'aria-hidden': true,
|
|
7361
|
+
children: CHECK_ICON_PATHS.map((d, index) =>
|
|
7362
|
+
jsx('path', { d, stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round' }, index),
|
|
7363
|
+
),
|
|
7364
|
+
})
|
|
7365
|
+
}
|
|
7366
|
+
|
|
7241
7367
|
// --- controller -----------------------------------------------------------
|
|
7242
7368
|
|
|
7243
7369
|
class DeletionController {
|
|
@@ -7265,6 +7391,11 @@ window.__ModuleLoader__.load({
|
|
|
7265
7391
|
loaded: false,
|
|
7266
7392
|
loadError: false,
|
|
7267
7393
|
dialog: null,
|
|
7394
|
+
// Multi-select: while `selecting` is on every deletable row offers a
|
|
7395
|
+
// checkbox, and one request lands every checked window at once.
|
|
7396
|
+
selecting: false,
|
|
7397
|
+
// selectionKey -> target, so a row keeps its mark across refreshes.
|
|
7398
|
+
selected: new Map(),
|
|
7268
7399
|
pending: false,
|
|
7269
7400
|
failure: null,
|
|
7270
7401
|
// How many unrelated nodes the last attempt reported it would also
|
|
@@ -7351,6 +7482,95 @@ window.__ModuleLoader__.load({
|
|
|
7351
7482
|
return pending
|
|
7352
7483
|
}
|
|
7353
7484
|
|
|
7485
|
+
// Entering/leaving multi-select. Leaving drops the selection, so a later
|
|
7486
|
+
// entry never starts with rows the user no longer sees as marked.
|
|
7487
|
+
setSelecting(on) {
|
|
7488
|
+
if (this.view.pending) return
|
|
7489
|
+
const selecting = on === true
|
|
7490
|
+
if (selecting === this.view.selecting) return
|
|
7491
|
+
this.publish({ selecting, selected: selecting ? this.view.selected : new Map(), dialog: null, failure: null, foreignCount: 0 })
|
|
7492
|
+
}
|
|
7493
|
+
|
|
7494
|
+
toggleSelected(key, target) {
|
|
7495
|
+
if (this.view.pending || typeof key !== 'string' || key === '' || target === null || target === undefined) return
|
|
7496
|
+
const selected = new Map(this.view.selected)
|
|
7497
|
+
if (selected.has(key)) selected.delete(key)
|
|
7498
|
+
else selected.set(key, target)
|
|
7499
|
+
this.publish({ selected })
|
|
7500
|
+
}
|
|
7501
|
+
|
|
7502
|
+
// The batch entry: with "ask" on it opens the confirmation dialog, with it
|
|
7503
|
+
// off it deletes straight away and only surfaces a dialog when the attempt
|
|
7504
|
+
// failed (a silent failure would have nowhere to go).
|
|
7505
|
+
requestBatch() {
|
|
7506
|
+
const count = this.view.selected.size
|
|
7507
|
+
if (count === 0 || this.view.pending) return
|
|
7508
|
+
if (getPrefs().ask) {
|
|
7509
|
+
this.publish({ dialog: { mode: 'batch', count }, failure: null, foreignCount: 0 })
|
|
7510
|
+
return
|
|
7511
|
+
}
|
|
7512
|
+
void this.performBatch().then((result) => {
|
|
7513
|
+
if (result.failure !== null) this.publish({ dialog: { mode: 'batch', count }, pending: false, failure: result.failure })
|
|
7514
|
+
})
|
|
7515
|
+
}
|
|
7516
|
+
|
|
7517
|
+
// One request carries every selected target. The host fuses the windows
|
|
7518
|
+
// and lands them back to back, so a batch can never land half of itself.
|
|
7519
|
+
async performBatch(options) {
|
|
7520
|
+
if (this.view.pending) return { failure: null }
|
|
7521
|
+
const force = options !== undefined && options.force === true
|
|
7522
|
+
const requested = [...this.view.selected.values()]
|
|
7523
|
+
if (requested.length === 0) return { failure: null }
|
|
7524
|
+
this.publish({ pending: true, failure: null, foreignCount: 0 })
|
|
7525
|
+
try {
|
|
7526
|
+
await this.load(true)
|
|
7527
|
+
// A target the fresh surface no longer holds means the log moved under
|
|
7528
|
+
// the selection: refuse the whole batch instead of landing the part
|
|
7529
|
+
// that still resolves.
|
|
7530
|
+
const resolved = requested.map((target) => this.resolveTarget(target))
|
|
7531
|
+
if (resolved.some((target) => target === null || target === undefined)) {
|
|
7532
|
+
this.publish({ pending: false, failure: 'stale' })
|
|
7533
|
+
return { failure: 'stale' }
|
|
7534
|
+
}
|
|
7535
|
+
const targets = resolved.map((target) => {
|
|
7536
|
+
const out = { mode: target.mode }
|
|
7537
|
+
if (typeof target.seq === 'number') out.seq = target.seq
|
|
7538
|
+
if (typeof target.turn === 'number') out.turn = target.turn
|
|
7539
|
+
if (typeof target.messageId === 'string') out.messageId = target.messageId
|
|
7540
|
+
return out
|
|
7541
|
+
})
|
|
7542
|
+
const res = await fetch(`${ROUTE_PREFIX}/delete`, {
|
|
7543
|
+
method: 'POST',
|
|
7544
|
+
headers: { 'content-type': 'application/json' },
|
|
7545
|
+
body: JSON.stringify({ sessionId: this.sessionId, targets, ...(force ? { force: true } : {}) }),
|
|
7546
|
+
})
|
|
7547
|
+
const data = await res.json().catch(() => ({}))
|
|
7548
|
+
if (!res.ok || !data.ok) {
|
|
7549
|
+
const code = data && data.code ? String(data.code) : 'generic'
|
|
7550
|
+
const foreignCount = data && data.details && typeof data.details.foreign === 'number' ? data.details.foreign : 0
|
|
7551
|
+
this.publish({ pending: false, failure: code, foreignCount })
|
|
7552
|
+
// `partial` means the log moved between two appends: re-read so the
|
|
7553
|
+
// transcript shows what actually landed, not the pre-batch picture.
|
|
7554
|
+
if (code === 'partial') await this.load(true)
|
|
7555
|
+
return { failure: code }
|
|
7556
|
+
}
|
|
7557
|
+
const hidden = new Map(this.view.hidden)
|
|
7558
|
+
for (const item of Array.isArray(data.hidden) ? data.hidden : []) {
|
|
7559
|
+
if (item && typeof item.seq === 'number') hidden.set(item.seq, typeof item.mode === 'string' ? item.mode : 'message')
|
|
7560
|
+
}
|
|
7561
|
+
const hiddenVia = new Map(this.view.hiddenVia)
|
|
7562
|
+
for (const [via, seq] of hiddenViaOf(data.hidden)) hiddenVia.set(via, seq)
|
|
7563
|
+
this.animateOnce = true
|
|
7564
|
+
// Leaving the mode after a landed batch keeps the next click an
|
|
7565
|
+
// ordinary delete: the marks are gone, so staying would only widen it.
|
|
7566
|
+
this.publish({ pending: false, selecting: false, dialog: null, hidden, hiddenVia, selected: new Map(), loaded: true, loadError: false })
|
|
7567
|
+
return { failure: null }
|
|
7568
|
+
} catch {
|
|
7569
|
+
this.publish({ pending: false, failure: 'generic' })
|
|
7570
|
+
return { failure: 'generic' }
|
|
7571
|
+
}
|
|
7572
|
+
}
|
|
7573
|
+
|
|
7354
7574
|
open(target) {
|
|
7355
7575
|
this.load()
|
|
7356
7576
|
this.publish({ dialog: target, failure: null })
|
|
@@ -7365,7 +7585,13 @@ window.__ModuleLoader__.load({
|
|
|
7365
7585
|
// nodes" report, which is the only refusal the dialog turns into a second
|
|
7366
7586
|
// question instead of a dead end.
|
|
7367
7587
|
async confirm(options) {
|
|
7368
|
-
|
|
7588
|
+
const force = options !== undefined && options.force === true
|
|
7589
|
+
const target = this.view.dialog
|
|
7590
|
+
if (target !== null && typeof target === 'object' && target.mode === 'batch') {
|
|
7591
|
+
await this.performBatch({ force })
|
|
7592
|
+
return
|
|
7593
|
+
}
|
|
7594
|
+
await this.perform(target, { force })
|
|
7369
7595
|
}
|
|
7370
7596
|
|
|
7371
7597
|
// The entry every action uses. With "ask" on, it opens the confirmation
|
|
@@ -7503,6 +7729,18 @@ window.__ModuleLoader__.load({
|
|
|
7503
7729
|
return typeof seq === 'number' ? { mode: 'turn', seq, label: 'turn' } : null
|
|
7504
7730
|
}
|
|
7505
7731
|
|
|
7732
|
+
// Stable identity of one selectable target across snapshot refreshes: a
|
|
7733
|
+
// message/step addresses a durable seq, a reply/turn a turn number. The key
|
|
7734
|
+
// is what the controller keys its selection map by, so a re-read that moves
|
|
7735
|
+
// a row's live seq cannot silently keep a stale mark.
|
|
7736
|
+
function selectionKey(target) {
|
|
7737
|
+
if (!target || typeof target !== 'object') return ''
|
|
7738
|
+
if (typeof target.seq === 'number') return target.mode + ':' + String(target.seq)
|
|
7739
|
+
if (typeof target.turn === 'number') return target.mode + ':t' + String(target.turn)
|
|
7740
|
+
if (typeof target.messageId === 'string') return target.mode + ':m' + target.messageId
|
|
7741
|
+
return ''
|
|
7742
|
+
}
|
|
7743
|
+
|
|
7506
7744
|
function seqsFor(node) {
|
|
7507
7745
|
const data = node.data || {}
|
|
7508
7746
|
const out = []
|
|
@@ -7660,7 +7898,7 @@ window.__ModuleLoader__.load({
|
|
|
7660
7898
|
host.className = kind === 'think' ? HOST_CLASS + ' ' + THINK_CLASS : HOST_CLASS
|
|
7661
7899
|
host.setAttribute(HOST_MARK, '1')
|
|
7662
7900
|
if (kind === 'think') host.setAttribute(THINK_MARK, '1')
|
|
7663
|
-
let button = host.querySelector('button')
|
|
7901
|
+
let button = host.querySelector('button.dshdt-action')
|
|
7664
7902
|
if (button === null) {
|
|
7665
7903
|
button = document.createElement('button')
|
|
7666
7904
|
button.type = 'button'
|
|
@@ -7684,7 +7922,20 @@ window.__ModuleLoader__.load({
|
|
|
7684
7922
|
host.appendChild(turn)
|
|
7685
7923
|
}
|
|
7686
7924
|
}
|
|
7687
|
-
|
|
7925
|
+
// The multi-select checkbox rides in the same host (same namespace, same
|
|
7926
|
+
// adoption rule); it is only ever VISIBLE while the mode is on.
|
|
7927
|
+
let check = null
|
|
7928
|
+
if (kind !== 'think') {
|
|
7929
|
+
check = host.querySelector('.dshdt-check')
|
|
7930
|
+
if (check === null) {
|
|
7931
|
+
check = document.createElement('button')
|
|
7932
|
+
check.type = 'button'
|
|
7933
|
+
check.className = 'dshdt-check dshdt-row-action'
|
|
7934
|
+
check.innerHTML = CHECK_ICON_MARKUP
|
|
7935
|
+
host.appendChild(check)
|
|
7936
|
+
}
|
|
7937
|
+
}
|
|
7938
|
+
return { host, button, turn, check }
|
|
7688
7939
|
}
|
|
7689
7940
|
|
|
7690
7941
|
// Drop every node this plugin injected. Matched through this plugin's own
|
|
@@ -7697,6 +7948,8 @@ window.__ModuleLoader__.load({
|
|
|
7697
7948
|
function sweepInjectedNodes() {
|
|
7698
7949
|
for (const host of document.querySelectorAll('[' + HOST_MARK + '="1"]')) host.remove()
|
|
7699
7950
|
for (const host of document.querySelectorAll('.' + HOST_CLASS)) host.remove()
|
|
7951
|
+
// A toggle parked on a row is not inside any host, so it is swept by name.
|
|
7952
|
+
for (const node of document.querySelectorAll('.dshdt-check')) node.remove()
|
|
7700
7953
|
// These entries now point at detached nodes; the next injection re-adopts
|
|
7701
7954
|
// from the DOM instead of trusting them.
|
|
7702
7955
|
rowActions = new WeakMap()
|
|
@@ -7770,11 +8023,23 @@ window.__ModuleLoader__.load({
|
|
|
7770
8023
|
const entry = rowActions.get(row)
|
|
7771
8024
|
if (!entry) return
|
|
7772
8025
|
entry.host.remove()
|
|
8026
|
+
// The toggle may be on the row rather than in the host while the mode is
|
|
8027
|
+
// on, so the row is swept of direct children that belong to the host.
|
|
8028
|
+
for (const node of row.querySelectorAll('.dshdt-check')) {
|
|
8029
|
+
if (node.parentElement === row) node.remove()
|
|
8030
|
+
}
|
|
7773
8031
|
rowActions.delete(row)
|
|
8032
|
+
delete row.dataset.dshdtSelect
|
|
8033
|
+
row.classList.remove('dshdt-select-on', 'dshdt-checked')
|
|
7774
8034
|
}
|
|
7775
8035
|
|
|
7776
|
-
function injectRowAction(row, node, target, turnTarget, controller, t) {
|
|
7777
|
-
|
|
8036
|
+
function injectRowAction(row, node, target, turnTarget, view, controller, t, pickTarget) {
|
|
8037
|
+
// A row can carry the multi-select badge without carrying a hover action:
|
|
8038
|
+
// a turn's answer text has no row-level scope of its own, only a reply
|
|
8039
|
+
// scope, and that scope already lives in the reply action strip.
|
|
8040
|
+
const badgeOnly = target === null
|
|
8041
|
+
const pick = pickTarget === undefined || pickTarget === null ? target : pickTarget
|
|
8042
|
+
const label = badgeOnly ? '' : t(`action.tooltip.${target.label}`)
|
|
7778
8043
|
let entry = rowActions.get(row)
|
|
7779
8044
|
if (!entry) {
|
|
7780
8045
|
// Never a second host on a row that already carries one: an earlier
|
|
@@ -7782,20 +8047,24 @@ window.__ModuleLoader__.load({
|
|
|
7782
8047
|
entry = adoptHost(findOwnedHost(row, false), 'row')
|
|
7783
8048
|
rowActions.set(row, entry)
|
|
7784
8049
|
}
|
|
7785
|
-
const { host, button, turn } = entry
|
|
8050
|
+
const { host, button, turn, check } = entry
|
|
8051
|
+
if (badgeOnly) host.dataset.dshdtBadgeOnly = '1'
|
|
8052
|
+
else delete host.dataset.dshdtBadgeOnly
|
|
7786
8053
|
if (button.getAttribute('aria-label') !== label) {
|
|
7787
8054
|
button.setAttribute('aria-label', label)
|
|
7788
8055
|
button.setAttribute('title', label)
|
|
7789
8056
|
}
|
|
7790
|
-
button.onclick =
|
|
7791
|
-
|
|
7792
|
-
event
|
|
7793
|
-
|
|
7794
|
-
|
|
8057
|
+
button.onclick = badgeOnly
|
|
8058
|
+
? null
|
|
8059
|
+
: (event) => {
|
|
8060
|
+
event.preventDefault()
|
|
8061
|
+
event.stopPropagation()
|
|
8062
|
+
controller.trigger(target)
|
|
8063
|
+
}
|
|
7795
8064
|
// The second action is wired only where a turn target exists (and stays
|
|
7796
8065
|
// hidden by the row attribute otherwise), so a row can never offer an
|
|
7797
8066
|
// operation the host would refuse for the wrong reason.
|
|
7798
|
-
if (turn !== null && turn !== undefined) {
|
|
8067
|
+
if (!badgeOnly && turn !== null && turn !== undefined) {
|
|
7799
8068
|
const turnLabel = t('action.tooltip.turn')
|
|
7800
8069
|
if (turn.getAttribute('aria-label') !== turnLabel) {
|
|
7801
8070
|
turn.setAttribute('aria-label', turnLabel)
|
|
@@ -7810,6 +8079,39 @@ window.__ModuleLoader__.load({
|
|
|
7810
8079
|
controller.trigger(turnTarget)
|
|
7811
8080
|
}
|
|
7812
8081
|
}
|
|
8082
|
+
// Multi-select: the same host carries the toggle. Outside selection mode
|
|
8083
|
+
// it enters the mode AND marks this row, so one click both opens the bar
|
|
8084
|
+
// and shows what the next click would do.
|
|
8085
|
+
if (check !== null && check !== undefined) {
|
|
8086
|
+
const key = selectionKey(pick)
|
|
8087
|
+
const selecting = view.selecting === true
|
|
8088
|
+
const selected = selecting && key !== '' && view.selected.has(key)
|
|
8089
|
+
if (selecting) {
|
|
8090
|
+
row.dataset.dshdtSelect = '1'
|
|
8091
|
+
row.classList.add('dshdt-select-on')
|
|
8092
|
+
// Physically move the node onto the row for the duration of the
|
|
8093
|
+
// mode; the same element and handler go back to the host after.
|
|
8094
|
+
if (check.parentElement !== row) row.appendChild(check)
|
|
8095
|
+
} else {
|
|
8096
|
+
delete row.dataset.dshdtSelect
|
|
8097
|
+
row.classList.remove('dshdt-select-on')
|
|
8098
|
+
if (check.parentElement !== host) host.appendChild(check)
|
|
8099
|
+
}
|
|
8100
|
+
if (selected) row.classList.add('dshdt-checked')
|
|
8101
|
+
else row.classList.remove('dshdt-checked')
|
|
8102
|
+
const checkLabel = selected ? t('action.tooltip.selected') : t('action.tooltip.select')
|
|
8103
|
+
if (check.getAttribute('aria-label') !== checkLabel) {
|
|
8104
|
+
check.setAttribute('aria-label', checkLabel)
|
|
8105
|
+
check.setAttribute('title', checkLabel)
|
|
8106
|
+
}
|
|
8107
|
+
check.setAttribute('aria-pressed', selected ? 'true' : 'false')
|
|
8108
|
+
check.onclick = (event) => {
|
|
8109
|
+
event.preventDefault()
|
|
8110
|
+
event.stopPropagation()
|
|
8111
|
+
if (!controller.view.selecting) controller.setSelecting(true)
|
|
8112
|
+
controller.toggleSelected(key, pick)
|
|
8113
|
+
}
|
|
8114
|
+
}
|
|
7813
8115
|
const anchor = node.kind === 'user' || node.kind === 'steering' ? row.querySelector('[class*="_actions"]') : null
|
|
7814
8116
|
if (anchor) {
|
|
7815
8117
|
host.classList.remove('dshdt-floating')
|
|
@@ -7947,13 +8249,22 @@ window.__ModuleLoader__.load({
|
|
|
7947
8249
|
const hidden = orphaned || isRowHidden(view.hidden, view.hiddenVia, seqs)
|
|
7948
8250
|
setRowHidden(row, hidden, animate)
|
|
7949
8251
|
const target = hidden ? null : liveTarget(targetFor(node), view.edits)
|
|
7950
|
-
|
|
8252
|
+
// A row with no scope of its own (the answer text of a turn) still earns
|
|
8253
|
+
// the badge when its turn's reply is deletable: ticking it deletes that
|
|
8254
|
+
// answer and keeps the prompt, which is the only scope the row owns.
|
|
8255
|
+
const turnNumber = turn === null || turn === '' ? null : Number(turn)
|
|
8256
|
+
const replyPick =
|
|
8257
|
+
!hidden && turnNumber !== null && Number.isFinite(turnNumber) && view.replyTurns.has(turnNumber)
|
|
8258
|
+
? { mode: 'reply', turn: turnNumber, label: 'reply' }
|
|
8259
|
+
: null
|
|
8260
|
+
const covered = target !== null || slotCoversRow(node) || replyPick !== null
|
|
7951
8261
|
const deletable = !hidden && covered && rowDeletable(node, seqs, view)
|
|
7952
8262
|
// The turn-delete entry rides on the same gates as the delete entry: a
|
|
7953
8263
|
// row whose content already left the context offers neither.
|
|
7954
8264
|
const turnTarget =
|
|
7955
8265
|
deletable && turnTargetFor(node) !== null && view.turnSeqs.has(target.seq) ? liveTarget(turnTargetFor(node), view.edits) : null
|
|
7956
|
-
|
|
8266
|
+
const pickTarget = target !== null ? target : deletable ? replyPick : null
|
|
8267
|
+
if (deletable && pickTarget !== null) injectRowAction(row, node, target, turnTarget, view, controller, t, pickTarget)
|
|
7957
8268
|
else removeRowAction(row)
|
|
7958
8269
|
if (deletable && target !== null && turnTarget !== null) row.dataset.dshdtTurn = '1'
|
|
7959
8270
|
else delete row.dataset.dshdtTurn
|
|
@@ -8056,6 +8367,63 @@ window.__ModuleLoader__.load({
|
|
|
8056
8367
|
})
|
|
8057
8368
|
}
|
|
8058
8369
|
|
|
8370
|
+
// The multi-select bar. Fixed placement keeps it out of the slot's layout:
|
|
8371
|
+
// `conversation.input.overlay` is drawn over the composer, so an in-flow bar
|
|
8372
|
+
// would push the input area around (and re-measure it) on every toggle.
|
|
8373
|
+
// Multi-select is a MODE: entering it has to be possible without hovering a
|
|
8374
|
+
// row first, so the entry lives in the Session header - always visible,
|
|
8375
|
+
// next to the other Session utilities. The per-row toggles only appear once
|
|
8376
|
+
// the mode is on (their rows carry `data-dshdt-select` then).
|
|
8377
|
+
function SelectToggle({ useDeletion, controller, t }) {
|
|
8378
|
+
const view = useDeletion((state) => state)
|
|
8379
|
+
const selecting = view.selecting === true
|
|
8380
|
+
const label = selecting ? t('select.cancel') : t('action.tooltip.select')
|
|
8381
|
+
return jsx('button', {
|
|
8382
|
+
type: 'button',
|
|
8383
|
+
className: selecting ? 'dshdt-header-select dshdt-header-select-on' : 'dshdt-header-select',
|
|
8384
|
+
'aria-label': label,
|
|
8385
|
+
'aria-pressed': selecting ? 'true' : 'false',
|
|
8386
|
+
title: label,
|
|
8387
|
+
disabled: view.pending,
|
|
8388
|
+
onClick: (event) => {
|
|
8389
|
+
event.preventDefault()
|
|
8390
|
+
event.stopPropagation()
|
|
8391
|
+
controller.setSelecting(!selecting)
|
|
8392
|
+
},
|
|
8393
|
+
children: jsx(CheckIcon, {}),
|
|
8394
|
+
})
|
|
8395
|
+
}
|
|
8396
|
+
|
|
8397
|
+
function SelectionBar({ view, controller, t }) {
|
|
8398
|
+
if (view.selecting !== true) return null
|
|
8399
|
+
const count = view.selected.size
|
|
8400
|
+
return jsxs('div', {
|
|
8401
|
+
className: 'dshdt-selectbar',
|
|
8402
|
+
role: 'toolbar',
|
|
8403
|
+
children: [
|
|
8404
|
+
jsx('span', {
|
|
8405
|
+
className: 'dshdt-selectbar-count',
|
|
8406
|
+
children: `${t('select.count')} ${count}${t('select.unit')}`,
|
|
8407
|
+
}),
|
|
8408
|
+
jsx(primitives.Button, {
|
|
8409
|
+
variant: 'ghost',
|
|
8410
|
+
size: 'md',
|
|
8411
|
+
disabled: view.pending,
|
|
8412
|
+
onClick: () => controller.setSelecting(false),
|
|
8413
|
+
children: t('select.cancel'),
|
|
8414
|
+
}),
|
|
8415
|
+
jsx(primitives.Button, {
|
|
8416
|
+
variant: 'primary',
|
|
8417
|
+
size: 'md',
|
|
8418
|
+
className: 'dshdt-danger',
|
|
8419
|
+
disabled: view.pending || count === 0,
|
|
8420
|
+
onClick: () => controller.requestBatch(),
|
|
8421
|
+
children: view.pending ? t('dialog.pending') : t('select.delete'),
|
|
8422
|
+
}),
|
|
8423
|
+
],
|
|
8424
|
+
})
|
|
8425
|
+
}
|
|
8426
|
+
|
|
8059
8427
|
function ConfirmDialog({ view, controller, t }) {
|
|
8060
8428
|
const target = view.dialog
|
|
8061
8429
|
const mode = target && typeof target.mode === 'string' ? target.mode : 'message'
|
|
@@ -8165,7 +8533,11 @@ window.__ModuleLoader__.load({
|
|
|
8165
8533
|
return jsxs(Fragment, {
|
|
8166
8534
|
// The slot root carries this plugin's namespace as well (I3). It is
|
|
8167
8535
|
// React's node: the dispose sweep must not touch it.
|
|
8168
|
-
children: [
|
|
8536
|
+
children: [
|
|
8537
|
+
jsx('span', { hidden: true, 'data-dshdt-overlay': '1' }),
|
|
8538
|
+
jsx(SelectionBar, { view, controller, t }),
|
|
8539
|
+
jsx(ConfirmDialog, { view, controller, t }),
|
|
8540
|
+
],
|
|
8169
8541
|
})
|
|
8170
8542
|
}
|
|
8171
8543
|
|
|
@@ -8216,6 +8588,20 @@ window.__ModuleLoader__.load({
|
|
|
8216
8588
|
),
|
|
8217
8589
|
)
|
|
8218
8590
|
|
|
8591
|
+
// The always-visible door into multi-select, beside the Session utilities.
|
|
8592
|
+
ctx.slots.inject('conversation.session.header.utilities', () =>
|
|
8593
|
+
ctx.slots.register(
|
|
8594
|
+
{
|
|
8595
|
+
name: 'conversation.session.header.utilities',
|
|
8596
|
+
id: 'delete-turn',
|
|
8597
|
+
order: 10,
|
|
8598
|
+
locale: NS,
|
|
8599
|
+
inject: (sessionId) => ({ hooks: { deletion: controllerFor(sessionId) }, controller: controllerFor(sessionId) }),
|
|
8600
|
+
},
|
|
8601
|
+
SelectToggle,
|
|
8602
|
+
),
|
|
8603
|
+
)
|
|
8604
|
+
|
|
8219
8605
|
ctx.slots.inject('conversation.input.overlay', () =>
|
|
8220
8606
|
ctx.slots.register(
|
|
8221
8607
|
{
|
|
@@ -9197,7 +9583,7 @@ window.__ModuleLoader__.load({
|
|
|
9197
9583
|
const NS = 'dsh-as-aistudio'
|
|
9198
9584
|
|
|
9199
9585
|
/** Keep in sync with package.json and src/components.js. */
|
|
9200
|
-
const PLUGIN_VERSION = '0.4.
|
|
9586
|
+
const PLUGIN_VERSION = '0.4.8'
|
|
9201
9587
|
|
|
9202
9588
|
/** Presence marker the live verifier reads. */
|
|
9203
9589
|
const DEBUG_KEY = '__DSH_AS_AISTUDIO__'
|
package/src/components.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
export const STUDIO_ID = 'dsh-as-aistudio'
|
|
27
27
|
|
|
28
28
|
/** Keep in sync with package.json and src/client.js. */
|
|
29
|
-
export const STUDIO_VERSION = '0.4.
|
|
29
|
+
export const STUDIO_VERSION = '0.4.8'
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* The AI Studio action strip this studio composes, in the order the user meets
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-delete-turn host half (dsh-delete-turn.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-delete-turn/src/index.js (v0.
|
|
3
|
+
// Source: ../dsh-delete-turn/src/index.js (v0.2.0, sha256 5dfc53e5c47750f5)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// GET /dsh-delete-turn/state?sessionId=<id>
|
|
13
13
|
// POST /dsh-delete-turn/delete { sessionId, mode, seq?, messageId?, turn? }
|
|
14
|
+
// { sessionId, targets: [{ mode, seq?, turn? }] }
|
|
14
15
|
//
|
|
15
16
|
// A deletion appends ONE turn-less `user/message` carrier carrying the official
|
|
16
17
|
// surface intent `{ surfaceOp: { op: 'replace', startSeq, endSeq } }` and the
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
hiddenEntriesOfFold,
|
|
45
46
|
isBusy,
|
|
46
47
|
openTurn,
|
|
48
|
+
planBatch,
|
|
47
49
|
planRange,
|
|
48
50
|
turnableSeqs,
|
|
49
51
|
} from './dsh-delete-turn.logic.js'
|
|
@@ -420,8 +422,13 @@ async function stopActiveTurn(ctx, sessionId, events) {
|
|
|
420
422
|
}
|
|
421
423
|
|
|
422
424
|
async function deleteTarget(ctx, sessionId, body) {
|
|
425
|
+
// Two shapes: one target (`mode` + address) or a multi-select batch
|
|
426
|
+
// (`targets`). Both end in the SAME append path - a batch is just several
|
|
427
|
+
// disjoint range replaces landed back to back.
|
|
428
|
+
const batch = Array.isArray(body.targets)
|
|
429
|
+
if (batch && body.targets.length === 0) throw new HttpError(400, 'invalid', 'targets must not be empty')
|
|
423
430
|
const mode = typeof body.mode === 'string' ? body.mode : ''
|
|
424
|
-
if (!MODES.has(mode)) throw new HttpError(400, 'invalid', 'mode must be message, step, reply or turn')
|
|
431
|
+
if (!batch && !MODES.has(mode)) throw new HttpError(400, 'invalid', 'mode must be message, step, reply or turn')
|
|
425
432
|
const session = await resolveSession(ctx, sessionId)
|
|
426
433
|
if (!session || typeof session.append !== 'function') {
|
|
427
434
|
throw new HttpError(409, 'session-not-active', 'the session is not open in DSH')
|
|
@@ -438,17 +445,21 @@ async function deleteTarget(ctx, sessionId, body) {
|
|
|
438
445
|
}
|
|
439
446
|
|
|
440
447
|
const surfaceNodes = surfaceOf(ctx, sessionId, events)
|
|
441
|
-
let
|
|
448
|
+
let plans
|
|
442
449
|
try {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
450
|
+
plans = batch
|
|
451
|
+
? planBatch(events, surfaceNodes, body.targets)
|
|
452
|
+
: [
|
|
453
|
+
planRange(events, surfaceNodes, {
|
|
454
|
+
mode,
|
|
455
|
+
seq: typeof body.seq === 'number' ? body.seq : undefined,
|
|
456
|
+
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
457
|
+
turn: typeof body.turn === 'number' ? body.turn : undefined,
|
|
458
|
+
}),
|
|
459
|
+
]
|
|
449
460
|
} catch (error) {
|
|
450
461
|
if (error instanceof PlanError) {
|
|
451
|
-
const status = error.code === 'not-deletable' ? 400 : 409
|
|
462
|
+
const status = error.code === 'not-deletable' || error.code === 'invalid' ? 400 : 409
|
|
452
463
|
throw new HttpError(status, error.code, error.message)
|
|
453
464
|
}
|
|
454
465
|
throw error
|
|
@@ -458,14 +469,18 @@ async function deleteTarget(ctx, sessionId, body) {
|
|
|
458
469
|
// foreign injected row, say). Deleting them is not refused - it is a decision
|
|
459
470
|
// the user must make explicitly: without `force` the route reports how many
|
|
460
471
|
// there are, and the client turns that into a second, forced confirmation.
|
|
461
|
-
|
|
462
|
-
|
|
472
|
+
const foreignCount = plans.reduce((sum, plan) => sum + plan.foreign.length, 0)
|
|
473
|
+
if (foreignCount > 0 && body.force !== true) {
|
|
474
|
+
throw new HttpError(409, 'range-not-clean', `the window would also remove ${foreignCount} unrelated node(s)`, { foreign: foreignCount })
|
|
463
475
|
}
|
|
464
476
|
|
|
465
477
|
// The live surface is the append authority; a node that vanished between the
|
|
466
|
-
// read and this check means another writer landed first.
|
|
467
|
-
|
|
468
|
-
|
|
478
|
+
// read and this check means another writer landed first. Every window is
|
|
479
|
+
// checked before the first append, so a batch can never land half of itself.
|
|
480
|
+
for (const plan of plans) {
|
|
481
|
+
for (const seq of plan.shadowed) {
|
|
482
|
+
if (!surfaceNodes.includes(seq)) throw new HttpError(409, 'stale', 'the session changed, retry')
|
|
483
|
+
}
|
|
469
484
|
}
|
|
470
485
|
|
|
471
486
|
// The carrier must be a turn-less `user/message`.
|
|
@@ -484,31 +499,69 @@ async function deleteTarget(ctx, sessionId, body) {
|
|
|
484
499
|
// safest accepted content is a single ZERO-WIDTH SPACE (a readable marker
|
|
485
500
|
// like "[deleted]" is quoted back by the model, and empty content is refused
|
|
486
501
|
// by gateways whose adapter predates that skip).
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
502
|
+
const replacements = []
|
|
503
|
+
const hidden = []
|
|
504
|
+
const silent = silentCarrierFor(ctx, events)
|
|
505
|
+
// 标注(给日志读者用的“注释”,不是替换语义):整轮删除才会让可见轮号与日志轮号
|
|
506
|
+
// 分叉,所以只有 `mode: 'turn'` 成为墓碑(tombstone)。`deleteIndex` 是这条记录的
|
|
507
|
+
// 序号,配合 `deletedTurn` 足以复原“可见轮 ↔ 日志轮”的映射:
|
|
508
|
+
// 可见轮(L) = L − #{ 已删日志轮 < L }
|
|
509
|
+
let deleteOrdinal = 0
|
|
510
|
+
for (const event of events) {
|
|
511
|
+
if (event?.data?.source?.kind === `plugin:${PLUGIN_ID}` && Number.isSafeInteger(event?.data?.source?.deletedTurn)) deleteOrdinal += 1
|
|
512
|
+
}
|
|
513
|
+
// Ascending surface order, so each append is validated against a log the
|
|
514
|
+
// previous one already changed - the windows are disjoint, so a later
|
|
515
|
+
// window's anchors are untouched by an earlier append.
|
|
516
|
+
for (const plan of plans) {
|
|
517
|
+
// 一条载体一条注释;整轮删除才递增墓碑序号(同一批里的多轮删除必须各占一个号)。
|
|
518
|
+
const carrierSource = { kind: `plugin:${PLUGIN_ID}`, op: 'delete', turn: plan.turn }
|
|
519
|
+
if (plan.mode === 'turn') {
|
|
520
|
+
deleteOrdinal += 1
|
|
521
|
+
carrierSource.deletedTurn = plan.turn
|
|
522
|
+
carrierSource.deleteIndex = deleteOrdinal
|
|
523
|
+
}
|
|
524
|
+
let replacement
|
|
525
|
+
try {
|
|
526
|
+
replacement = session.append(
|
|
527
|
+
'user/message',
|
|
528
|
+
{
|
|
529
|
+
id: randomUUID(),
|
|
530
|
+
role: 'user',
|
|
531
|
+
content: silent ? [] : [{ type: 'text', text: '\u200b' }],
|
|
532
|
+
// v4 format: plugin wrappers are retired; the producer kind carries the id.
|
|
533
|
+
// `op` / `turn` 让任何读者一眼看出“谁、第几轮、删了什么”;整轮删除额外写
|
|
534
|
+
// `deletedTurn` + `deleteIndex`,它是可见轮号与日志轮号之间的唯一桥梁。
|
|
535
|
+
source: carrierSource,
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
|
|
539
|
+
sourceEventSeqs: plan.shadowed,
|
|
540
|
+
},
|
|
541
|
+
)
|
|
542
|
+
} catch (error) {
|
|
543
|
+
// A window refused after an earlier one already landed: report it as a
|
|
544
|
+
// partial batch instead of pretending nothing happened, so the client can
|
|
545
|
+
// re-read the log and show the truth.
|
|
546
|
+
if (replacements.length > 0) {
|
|
547
|
+
await flushSession(ctx, session).catch(() => undefined)
|
|
548
|
+
throw new HttpError(409, 'partial', `only ${replacements.length} of ${plans.length} window(s) landed: ${String((error && error.message) || error)}`, {
|
|
549
|
+
landed: replacements.length,
|
|
550
|
+
total: plans.length,
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
throw new HttpError(409, 'stale', `the surface refused the replacement: ${String((error && error.message) || error)}`)
|
|
554
|
+
}
|
|
555
|
+
replacements.push(replacement.seq)
|
|
556
|
+
for (const seq of plan.shadowed) hidden.push({ seq, mode: plan.mode })
|
|
505
557
|
}
|
|
506
558
|
const flush = await flushSession(ctx, session)
|
|
507
559
|
|
|
508
560
|
return {
|
|
509
|
-
replacementSeq:
|
|
561
|
+
replacementSeq: replacements[replacements.length - 1],
|
|
562
|
+
...(replacements.length > 1 ? { replacements } : {}),
|
|
510
563
|
...flush,
|
|
511
|
-
hidden
|
|
564
|
+
hidden,
|
|
512
565
|
}
|
|
513
566
|
}
|
|
514
567
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-delete-turn host half (dsh-delete-turn.logic.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-delete-turn/src/logic.js (v0.
|
|
3
|
+
// Source: ../dsh-delete-turn/src/logic.js (v0.2.0, sha256 6da16435446356ca)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -543,6 +543,77 @@ export function planRange(events, surfaceNodes, request) {
|
|
|
543
543
|
|
|
544
544
|
throw new PlanError('not-deletable', `unsupported mode ${String(mode)}`)
|
|
545
545
|
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Plan several UI targets into the fewest disjoint surface windows.
|
|
549
|
+
*
|
|
550
|
+
* A multi-select delete is not N independent deletes. Two selected rows can
|
|
551
|
+
* share one window (a step inside a reply, a message inside its turn), and
|
|
552
|
+
* deleting the first would make the second's anchors stale before it is even
|
|
553
|
+
* attempted. Every target is therefore planned against the SAME pre-delete
|
|
554
|
+
* surface, the windows are sorted by surface position, and neighbours —
|
|
555
|
+
* overlapping or directly adjacent — are fused into one replacement. Fusing
|
|
556
|
+
* only neighbours keeps the union exact: no node is swallowed that was not
|
|
557
|
+
* already inside one of the parts, so the foreign report stays the one the
|
|
558
|
+
* single-target path would have produced.
|
|
559
|
+
*
|
|
560
|
+
* @param events - complete contiguous raw event log.
|
|
561
|
+
* @param surfaceNodes - current surface seqs in model order.
|
|
562
|
+
* @param targets - array of `{ mode, seq?, messageId?, turn? }`.
|
|
563
|
+
* @returns one plan per fused window, in surface order. `mode` is the single
|
|
564
|
+
* part's mode when nothing was fused and `batch` otherwise; `parts` names the
|
|
565
|
+
* targets the window covers.
|
|
566
|
+
* @throws {PlanError} when any target cannot be planned.
|
|
567
|
+
*/
|
|
568
|
+
export function planBatch(events, surfaceNodes, targets) {
|
|
569
|
+
if (!Array.isArray(targets) || targets.length === 0) {
|
|
570
|
+
throw new PlanError('invalid', 'targets must be a non-empty array')
|
|
571
|
+
}
|
|
572
|
+
const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
|
|
573
|
+
const bySeq = new Map(events.map((event) => [event.seq, event]))
|
|
574
|
+
const plans = []
|
|
575
|
+
const seen = new Set()
|
|
576
|
+
for (const request of targets) {
|
|
577
|
+
if (!request || typeof request !== 'object') throw new PlanError('invalid', 'every target must be an object')
|
|
578
|
+
const key = [request.mode, request.seq, request.turn, request.messageId].join('|')
|
|
579
|
+
if (seen.has(key)) continue
|
|
580
|
+
seen.add(key)
|
|
581
|
+
const plan = planRange(events, surfaceNodes, request)
|
|
582
|
+
plans.push({ plan, startIdx: nodeIndex.get(plan.startSeq), endIdx: nodeIndex.get(plan.endSeq) })
|
|
583
|
+
}
|
|
584
|
+
plans.sort((a, b) => a.startIdx - b.startIdx || a.endIdx - b.endIdx)
|
|
585
|
+
const groups = []
|
|
586
|
+
for (const item of plans) {
|
|
587
|
+
const last = groups[groups.length - 1]
|
|
588
|
+
if (last !== undefined && item.startIdx <= last.endIdx + 1) {
|
|
589
|
+
last.endIdx = Math.max(last.endIdx, item.endIdx)
|
|
590
|
+
last.parts.push(item.plan)
|
|
591
|
+
continue
|
|
592
|
+
}
|
|
593
|
+
groups.push({ startIdx: item.startIdx, endIdx: item.endIdx, parts: [item.plan] })
|
|
594
|
+
}
|
|
595
|
+
return groups.map((group) => {
|
|
596
|
+
const shadowed = surfaceNodes.slice(group.startIdx, group.endIdx + 1)
|
|
597
|
+
const members = []
|
|
598
|
+
for (const plan of group.parts) {
|
|
599
|
+
for (const seq of plan.shadowed) if (!members.includes(seq)) members.push(seq)
|
|
600
|
+
}
|
|
601
|
+
const foreign = foreignNodes(shadowed, members, bySeq)
|
|
602
|
+
const first = group.parts[0]
|
|
603
|
+
return {
|
|
604
|
+
mode: group.parts.length === 1 ? first.mode : 'batch',
|
|
605
|
+
targetSeq: first.targetSeq,
|
|
606
|
+
startSeq: shadowed[0],
|
|
607
|
+
endSeq: shadowed[shadowed.length - 1],
|
|
608
|
+
shadowed,
|
|
609
|
+
foreign,
|
|
610
|
+
turn: first.turn,
|
|
611
|
+
step: first.step,
|
|
612
|
+
parts: group.parts.map((plan) => ({ mode: plan.mode, targetSeq: plan.targetSeq })),
|
|
613
|
+
}
|
|
614
|
+
})
|
|
615
|
+
}
|
|
616
|
+
|
|
546
617
|
// --- target resolution -------------------------------------------------------
|
|
547
618
|
|
|
548
619
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-edit-turn host half (dsh-edit-turn.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-edit-turn/lib/index.js (v0.5.
|
|
3
|
+
// Source: ../dsh-edit-turn/lib/index.js (v0.5.13, sha256 85d229824536a84b)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -85,7 +85,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
|
|
|
85
85
|
export const name = PLUGIN_ID
|
|
86
86
|
|
|
87
87
|
/** Keep in sync with package.json and lib/client.js. */
|
|
88
|
-
export const PLUGIN_VERSION = '0.5.
|
|
88
|
+
export const PLUGIN_VERSION = '0.5.13'
|
|
89
89
|
|
|
90
90
|
// A ring of the last few requests, answering "did the browser half even ask, and
|
|
91
91
|
// what did it get?" without needing its console. Read-only diagnostics: the
|
|
@@ -1491,6 +1491,13 @@ function eventsFromLive(session) {
|
|
|
1491
1491
|
// Live-preferred read through the public query service; the live session's own
|
|
1492
1492
|
// snapshot is the fallback when the service is absent.
|
|
1493
1493
|
async function readEvents(ctx, sessionId) {
|
|
1494
|
+
// A session the host has open answers from memory. Going through the query
|
|
1495
|
+
// service instead makes the route re-decode the whole log from disk first:
|
|
1496
|
+
// measured 1.4 s for a 15 MB log, while every computation this route does on
|
|
1497
|
+
// top of it adds up to a few tens of milliseconds. That read was the entire
|
|
1498
|
+
// reason the edit entry took seconds to appear.
|
|
1499
|
+
const live = eventsFromLive(findLiveSession(ctx, sessionId))
|
|
1500
|
+
if (live !== undefined && live.length > 0) return live
|
|
1494
1501
|
const query = ctx.get('sessionQuery')
|
|
1495
1502
|
if (query && typeof query.readSession === 'function') {
|
|
1496
1503
|
try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-edit-turn host half (dsh-edit-turn.patch.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-edit-turn/lib/patch.js (v0.5.
|
|
3
|
+
// Source: ../dsh-edit-turn/lib/patch.js (v0.5.13, sha256 5e1e8cce1d168f6c)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-rerun-turn host half (dsh-rerun-turn.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
|
|
2
2
|
//
|
|
3
|
-
// Source: ../dsh-rerun-turn/lib/index.js (v0.2.
|
|
3
|
+
// Source: ../dsh-rerun-turn/lib/index.js (v0.2.7, sha256 9f163488f55fb398)
|
|
4
4
|
// Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
|
|
5
5
|
//
|
|
6
6
|
// src/shell.js imports the copy named after the component, which is why the
|
|
@@ -90,7 +90,7 @@ export const PLUGIN_ID = 'dsh-rerun-turn'
|
|
|
90
90
|
export const name = PLUGIN_ID
|
|
91
91
|
|
|
92
92
|
/** Keep in sync with package.json and lib/client.js. */
|
|
93
|
-
export const PLUGIN_VERSION = '0.2.
|
|
93
|
+
export const PLUGIN_VERSION = '0.2.7'
|
|
94
94
|
|
|
95
95
|
const ROUTE_PREFIX = '/dsh-rerun-turn'
|
|
96
96
|
// A session id is an OPAQUE string to DSH: the host's own schema is
|
|
@@ -952,10 +952,34 @@ export function planRerun(events, surfaceNodes, request) {
|
|
|
952
952
|
}
|
|
953
953
|
const span = resolved.span
|
|
954
954
|
const promptEvent = bySeq.get(livePromptSeq)
|
|
955
|
-
const
|
|
956
|
-
if (
|
|
955
|
+
const slotWindow = surfaceNodes.slice(nodeIndex.get(livePromptSeq))
|
|
956
|
+
if (slotWindow.length === 0 || slotWindow[0] !== livePromptSeq) {
|
|
957
957
|
throw new RerunPlanError('not-rerunnable', 'the prompt does not open a rerun window')
|
|
958
958
|
}
|
|
959
|
+
// 单一来源:窗口就是「提示词槽位到表面末尾」这一段**表面**节点,按表面顺序。
|
|
960
|
+
// 宿主的 replace 按表面位置解析(`indexOf(startSeq)`..`indexOf(endSeq)`),所以
|
|
961
|
+
// 区间端点取这段的首尾即可;区间覆盖的正是这整段,不会多也不会少。
|
|
962
|
+
const shadowed = slotWindow
|
|
963
|
+
// 窗口里若站着一条 edit 载体(它占着提示词的槽位,正文是新文本),把它顶掉的原消息
|
|
964
|
+
// 也一并退役:否则界面在 rerun 期间会把原文重新渲染出来(“回复数字1”闪回)。
|
|
965
|
+
// 只加进 `retired`(写进 sourceEventSeqs 让客户端一起隐藏),不加进 `shadowed`
|
|
966
|
+
// (重放不复制它,否则提示词会出现两份)。
|
|
967
|
+
const replacements = foldSurface(events).replacements
|
|
968
|
+
const carrierTargets = new Map()
|
|
969
|
+
for (const replacement of replacements) carrierTargets.set(replacement.seq, replacement.shadowed)
|
|
970
|
+
const retired = new Set(shadowed)
|
|
971
|
+
for (const seq of shadowed) {
|
|
972
|
+
const targets = carrierTargets.get(seq)
|
|
973
|
+
if (targets === undefined) continue
|
|
974
|
+
for (const target of targets) retired.add(target)
|
|
975
|
+
}
|
|
976
|
+
// 只有**提示词槽位上那条载体**被遮蔽时会让别人重新露出来:它就是 edit 载体,
|
|
977
|
+
// 它的替换目标(原文)会在窗口盖掉它的一瞬回到表面。只把这**一跳**的目标交出去
|
|
978
|
+
// 给客户端做乐观隐藏;多跳链上的目标属于别的轮次,隐藏它们会把界面清空。
|
|
979
|
+
const restored = carrierTargets.get(shadowed[0]) ?? []
|
|
980
|
+
const retiredList = [...retired].sort((left, right) => left - right)
|
|
981
|
+
const windowLo = retiredList[0]
|
|
982
|
+
const windowHi = retiredList[retiredList.length - 1]
|
|
959
983
|
if (promptEvent.data && Array.isArray(promptEvent.data.content)) {
|
|
960
984
|
const files = promptEvent.data.content.filter((block) => block && block.type === 'file')
|
|
961
985
|
if (files.length > 0) {
|
|
@@ -968,9 +992,8 @@ export function planRerun(events, surfaceNodes, request) {
|
|
|
968
992
|
// back. When the prompt never lands (admission failed), the walk falls back
|
|
969
993
|
// to the rerun turn's own `turn/start`, so the copies reproduce the whole
|
|
970
994
|
// window and the context keeps its content.
|
|
971
|
-
|
|
972
|
-
let
|
|
973
|
-
for (let seq = lastShadowed + 1; ; seq += 1) {
|
|
995
|
+
let logTo = windowHi
|
|
996
|
+
for (let seq = windowHi + 1; ; seq += 1) {
|
|
974
997
|
const event = bySeq.get(seq)
|
|
975
998
|
if (event === undefined) break
|
|
976
999
|
if (isSurfaceEvent(event)) break
|
|
@@ -986,9 +1009,15 @@ export function planRerun(events, surfaceNodes, request) {
|
|
|
986
1009
|
turn,
|
|
987
1010
|
targetSeq: target.seq,
|
|
988
1011
|
promptSeq: livePromptSeq,
|
|
1012
|
+
// 宿主按**表面位置**遮蔽:range 的两个端点必须本身在表面上,区间内(表面顺序)
|
|
1013
|
+
// 的节点被一起替换。所以端点取窗口的首尾表面节点,而不是 seq 最小/最大——edit
|
|
1014
|
+
// 载体 seq 靠后但槽位在前,拿 seq 当端点会被宿主拒绝(`start seq 13 not found`)。
|
|
989
1015
|
startSeq: shadowed[0],
|
|
990
1016
|
endSeq: shadowed[shadowed.length - 1],
|
|
991
1017
|
shadowed,
|
|
1018
|
+
retired: retiredList,
|
|
1019
|
+
// 客户端在生成期间额外隐藏的行:只含“提示词槽位载体”的直接替换目标。
|
|
1020
|
+
restored,
|
|
992
1021
|
// The seq where the replay's copies start in the normal path - the next
|
|
993
1022
|
// turn's own bracket - and the fallback start (the rerun turn's bracket)
|
|
994
1023
|
// for the failed-admission replay. `rerunTurnEnd` closes the rerun turn;
|
|
@@ -1222,7 +1251,11 @@ export function buildShadowWrites(
|
|
|
1222
1251
|
type: 'user/message',
|
|
1223
1252
|
surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
|
|
1224
1253
|
// 写入前规范化:嵌套数组/重复项/非整数都会让宿主的严格校验器拒绝整条事件。
|
|
1225
|
-
|
|
1254
|
+
// 引用 = 将要退役的全部事件:窗口里的表面节点 + 被 edit 载体顶掉的原消息
|
|
1255
|
+
// (客户端据此把原文行一起隐藏,不会在 rerun 期间闪回旧提示词)。
|
|
1256
|
+
// 重放用的是 foldSurface 算出的真实遮蔽槽位,不读这个列表,所以多出来的
|
|
1257
|
+
// 原消息不会被复制第二遍。
|
|
1258
|
+
sourceEventSeqs: normalizeShadowed(plan.retired ?? plan.shadowed),
|
|
1226
1259
|
data: {
|
|
1227
1260
|
id: randomUUID(),
|
|
1228
1261
|
role: 'user',
|
|
@@ -1376,30 +1409,142 @@ export function buildReplayWrites(events, plan, baseTurn, rerunId, startSeq, pro
|
|
|
1376
1409
|
const shadowedSet = new Set(plan.shadowed)
|
|
1377
1410
|
const editPatches = editPatchesOf(events)
|
|
1378
1411
|
const turnOf = turnIndex(events)
|
|
1412
|
+
// A replacement carrier (an edit rewriting a turn's prompt) is appended AFTER
|
|
1413
|
+
// the turn closed, so the bracket walk has no turn for it. Its slot belongs to
|
|
1414
|
+
// the original it replaced: follow the chain to the original and read ITS turn.
|
|
1415
|
+
const carrierTargets = new Map()
|
|
1416
|
+
for (const replacement of foldSurface(events).replacements) carrierTargets.set(replacement.seq, replacement.shadowed)
|
|
1417
|
+
const turnFor = (seq) => {
|
|
1418
|
+
const own = turnOf.get(seq)
|
|
1419
|
+
if (typeof own === 'number') return own
|
|
1420
|
+
const seen = new Set([seq])
|
|
1421
|
+
let queue = [...(carrierTargets.get(seq) ?? [])]
|
|
1422
|
+
while (queue.length > 0) {
|
|
1423
|
+
const next = queue.shift()
|
|
1424
|
+
if (seen.has(next)) continue
|
|
1425
|
+
seen.add(next)
|
|
1426
|
+
const turn = turnOf.get(next)
|
|
1427
|
+
if (typeof turn === 'number') return turn
|
|
1428
|
+
queue = queue.concat(carrierTargets.get(next) ?? [])
|
|
1429
|
+
}
|
|
1430
|
+
return undefined
|
|
1431
|
+
}
|
|
1379
1432
|
const shadowedTurns = new Set()
|
|
1380
1433
|
for (const seq of plan.shadowed) {
|
|
1381
|
-
const turn =
|
|
1434
|
+
const turn = turnFor(seq)
|
|
1382
1435
|
if (typeof turn === 'number') shadowedTurns.add(turn)
|
|
1383
1436
|
}
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1437
|
+
// 按**表面顺序**重放。表面顺序与日志顺序不是一回事:替换事件占的是被替换节点的
|
|
1438
|
+
// 槽位,它的 seq 却更大(edit 把第 2 轮提问改在 seq 33,而它那条回复是 seq 30),
|
|
1439
|
+
// 按日志顺序复制会把「回复数字4」排到它自己那条回复的后面,模型看到的消息顺序就
|
|
1440
|
+
// 变了。这里按 turn → 轮级消息(user)→ step → step 内消息(assistant/tool)
|
|
1441
|
+
// 的槽位顺序排列,括号事件取原日志里的那一条(没有就合成一条)。
|
|
1442
|
+
const bySeq = eventIndex(events)
|
|
1443
|
+
// Which turns the replay copies: the rerun turn's own content is replaced by the
|
|
1444
|
+
// fresh answer, so the normal path starts at the NEXT turn; when the prompt never
|
|
1445
|
+
// landed (fallback) the whole window, rerun turn included, is copied. A caller
|
|
1446
|
+
// that hands us a trimmed plan without `turn` (the replay task re-reads it from
|
|
1447
|
+
// the carrier) still works: the walk start names the first copied turn.
|
|
1448
|
+
const rerunTurn = typeof plan.turn === 'number' ? plan.turn : null
|
|
1449
|
+
const walkTurn = typeof plan.logFrom === 'number' ? turnOf.get(plan.logFrom) : undefined
|
|
1450
|
+
const copyFromTurn =
|
|
1451
|
+
rerunTurn !== null
|
|
1452
|
+
? promptCopied === true
|
|
1453
|
+
? rerunTurn
|
|
1454
|
+
: rerunTurn + 1
|
|
1455
|
+
: typeof walkTurn === 'number'
|
|
1456
|
+
? walkTurn
|
|
1457
|
+
: -Infinity
|
|
1458
|
+
// Where each surface event sits in the log's bracket tree: a user message written
|
|
1459
|
+
// inside a step belongs to that step; a replacement carrier appended after the
|
|
1460
|
+
// turn closed belongs to the turn (it stands in a slot inside it).
|
|
1461
|
+
const placement = new Map()
|
|
1462
|
+
{
|
|
1463
|
+
let openTurn = null
|
|
1464
|
+
let openStep = null
|
|
1465
|
+
for (const event of events) {
|
|
1466
|
+
if (event.type === 'turn/start') {
|
|
1467
|
+
openTurn = event.data.turn
|
|
1468
|
+
openStep = null
|
|
1469
|
+
continue
|
|
1470
|
+
}
|
|
1471
|
+
if (event.type === 'turn/end') {
|
|
1472
|
+
openTurn = null
|
|
1473
|
+
openStep = null
|
|
1474
|
+
continue
|
|
1475
|
+
}
|
|
1476
|
+
if (event.type === 'step/start') {
|
|
1477
|
+
openStep = event.data.step
|
|
1478
|
+
continue
|
|
1479
|
+
}
|
|
1480
|
+
if (event.type === 'step/end') {
|
|
1481
|
+
openStep = null
|
|
1482
|
+
continue
|
|
1483
|
+
}
|
|
1484
|
+
if (!isSurfaceEvent(event)) continue
|
|
1485
|
+
placement.set(event.seq, { turn: openTurn, step: openStep })
|
|
1394
1486
|
}
|
|
1395
|
-
return false
|
|
1396
1487
|
}
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1399
|
-
|
|
1488
|
+
const placeOf = (seq) => {
|
|
1489
|
+
const placed = placement.get(seq)
|
|
1490
|
+
if (placed !== undefined && typeof placed.turn === 'number') return placed
|
|
1491
|
+
return { turn: turnFor(seq), step: null }
|
|
1492
|
+
}
|
|
1493
|
+
const buckets = new Map()
|
|
1494
|
+
for (const seq of plan.shadowed) {
|
|
1400
1495
|
const event = bySeq.get(seq)
|
|
1401
1496
|
if (event === undefined) continue
|
|
1402
|
-
|
|
1497
|
+
const place = placeOf(seq)
|
|
1498
|
+
if (typeof place.turn !== 'number' || place.turn < copyFromTurn) continue
|
|
1499
|
+
let bucket = buckets.get(place.turn)
|
|
1500
|
+
if (bucket === undefined) {
|
|
1501
|
+
bucket = { level: [], steps: new Map() }
|
|
1502
|
+
buckets.set(place.turn, bucket)
|
|
1503
|
+
}
|
|
1504
|
+
if (place.step === null) {
|
|
1505
|
+
bucket.level.push(event)
|
|
1506
|
+
} else {
|
|
1507
|
+
const list = bucket.steps.get(place.step)
|
|
1508
|
+
if (list === undefined) bucket.steps.set(place.step, [event])
|
|
1509
|
+
else list.push(event)
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
const bracketOf = (type, turn, step) =>
|
|
1513
|
+
events.find(
|
|
1514
|
+
(event) =>
|
|
1515
|
+
event.type === type &&
|
|
1516
|
+
event.data !== null &&
|
|
1517
|
+
event.data !== undefined &&
|
|
1518
|
+
event.data.turn === turn &&
|
|
1519
|
+
(step === undefined || event.data.step === step),
|
|
1520
|
+
)
|
|
1521
|
+
const callIdsOf = (turn, step) =>
|
|
1522
|
+
events.filter(
|
|
1523
|
+
(event) =>
|
|
1524
|
+
event.type === 'tool/call' &&
|
|
1525
|
+
event.data !== null &&
|
|
1526
|
+
event.data !== undefined &&
|
|
1527
|
+
event.data.turn === turn &&
|
|
1528
|
+
event.data.step === step,
|
|
1529
|
+
)
|
|
1530
|
+
const pushWithCalls = (event, turn) => {
|
|
1531
|
+
range.push(event)
|
|
1532
|
+
if (event.type !== 'assistant/message') return
|
|
1533
|
+
const step = event.data !== null && event.data !== undefined && typeof event.data.step === 'number' ? event.data.step : null
|
|
1534
|
+
if (step === null) return
|
|
1535
|
+
for (const call of callIdsOf(turn, step)) if (!range.includes(call)) range.push(call)
|
|
1536
|
+
}
|
|
1537
|
+
const range = []
|
|
1538
|
+
for (const turn of [...buckets.keys()].sort((left, right) => left - right)) {
|
|
1539
|
+
const bucket = buckets.get(turn)
|
|
1540
|
+
range.push(bracketOf('turn/start', turn) ?? { type: 'turn/start', data: { turn } })
|
|
1541
|
+
for (const event of bucket.level) pushWithCalls(event, turn)
|
|
1542
|
+
for (const step of [...bucket.steps.keys()].sort((left, right) => left - right)) {
|
|
1543
|
+
range.push(bracketOf('step/start', turn, step) ?? { type: 'step/start', data: { turn, step } })
|
|
1544
|
+
for (const event of bucket.steps.get(step)) pushWithCalls(event, turn)
|
|
1545
|
+
range.push(bracketOf('step/end', turn, step) ?? { type: 'step/end', data: { turn, step } })
|
|
1546
|
+
}
|
|
1547
|
+
range.push(bracketOf('turn/end', turn) ?? { type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
|
1403
1548
|
}
|
|
1404
1549
|
// Tool pairing facts for the whole range, so assistant content can be
|
|
1405
1550
|
// corrected before anything is written.
|
|
@@ -2324,6 +2469,9 @@ async function applyRerun(ctx, sessionId, body, config) {
|
|
|
2324
2469
|
rerunId,
|
|
2325
2470
|
carrierSeq: plan.startSeq,
|
|
2326
2471
|
shadowed: plan.shadowed,
|
|
2472
|
+
// 客户端在生成期间要额外隐藏的行:只含提示词槽位那条载体的**直接**替换目标
|
|
2473
|
+
// (即 edit 的原文)。多跳链上的其它目标属于别的轮次,隐藏它们会把界面清空。
|
|
2474
|
+
retired: plan.restored ?? [],
|
|
2327
2475
|
turn: plan.turn,
|
|
2328
2476
|
promptSeq: plan.promptSeq,
|
|
2329
2477
|
...flush,
|
package/src/vendor/versions.json
CHANGED