dsh-muv-table 0.2.12 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -1
- package/lib/client.js +784 -60
- package/lib/index.js +54 -8
- package/package.json +6 -3
- package/test-card-ops.mjs +152 -0
- package/test-preset-resolve.mjs +89 -4
- package/test-var-section.mjs +272 -0
package/lib/client.js
CHANGED
|
@@ -33,6 +33,698 @@ window.__ModuleLoader__.load({
|
|
|
33
33
|
font: 'var(--dsw-font-family)'
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// ============ 卡运维(卡相关的运维入口,全部挂在 muv-table 自己的面板里)============
|
|
37
|
+
//
|
|
38
|
+
// 用户口径(2026-09-23):「我有 muv-table 面板,不需要做到酒馆面板里面,**集成到
|
|
39
|
+
// muv-table 面板里面就行了**」⇒ 这一块的入口只出现在侧边栏「MUV 表格」打开的这个
|
|
40
|
+
// 视图里(面板内「🧰 卡运维」),不在酒馆的管理面板里再放一份。
|
|
41
|
+
//
|
|
42
|
+
// 数据源全是 DSH 同源 HTTP,**本仓库不改服务端**:
|
|
43
|
+
// POST /api/tavern/greeting/insert { sessionId? } (dsh-tavern-v2)
|
|
44
|
+
// → 把当前卡的开场白注入**当前会话末尾**(旧会话也能用)。
|
|
45
|
+
// · 已注入过 ⇒ 200 { ok:false, error:'greeting-already-present' } —— 不是错误;
|
|
46
|
+
// · 路由不存在 / 酒馆没启动 ⇒ 降级提示,**不许抛错崩面板**。
|
|
47
|
+
// GET /api/muv-engine/card-scripts?cardName=&presetDir= (dsh-muv-engine)
|
|
48
|
+
// → { ok, scripts:[…], total, enabled, source, fileName, reason }
|
|
49
|
+
// GET /api/muv-engine/state?sessionId=…
|
|
50
|
+
// → { ok, state:{ data, updatedAt } }(无状态时 state 为 null)
|
|
51
|
+
const CARD_OPS_BUILD = 'dsh-muv-table 0.2.12 (card-ops)'
|
|
52
|
+
|
|
53
|
+
// ★ 下面这段被 test-card-ops.mjs **逐字提取执行**(验"手势 → 请求体")。
|
|
54
|
+
// 因此这段里禁止引用 React / DOM / 外层闭包变量,只许用入参 + 全局 fetch;
|
|
55
|
+
// 两端标记行也要保持原样。
|
|
56
|
+
// [[card-ops-greeting-start]]
|
|
57
|
+
async function cardOpsInsertGreeting(input) {
|
|
58
|
+
const inp = input || {}
|
|
59
|
+
const callFetch = inp.fetch || (typeof fetch === 'function' ? fetch : null)
|
|
60
|
+
const sessionId = String(inp.sessionId || '')
|
|
61
|
+
if (!callFetch) return { kind: 'degrade', message: '⚠ 当前环境不支持 fetch,无法注入开场白' }
|
|
62
|
+
let res = null
|
|
63
|
+
try {
|
|
64
|
+
res = await callFetch('/api/tavern/greeting/insert', {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json' },
|
|
67
|
+
// 只带会话 id:服务端按会话**权威**解析 preset。presetId(酒馆 DOM / localStorage
|
|
68
|
+
// 那份)会「粘住」上一个会话的值,本仓库已整条删除该读取路径(见文件上方注释与
|
|
69
|
+
// docs/04 排错手册的串台条目)——不把猜出来的预设发出去。
|
|
70
|
+
body: JSON.stringify({ sessionId: sessionId || undefined })
|
|
71
|
+
})
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return { kind: 'degrade', message: '⚠ 开场白接口不可达(酒馆插件未启动?)—— ' + ((e && e.message) || String(e)) }
|
|
74
|
+
}
|
|
75
|
+
let d = null
|
|
76
|
+
try { d = await res.json() } catch (_) { d = null }
|
|
77
|
+
if (!d || typeof d !== 'object') {
|
|
78
|
+
// 路由没实现时拿到的是 404/405 的**非 JSON** 响应 ⇒ 降级(面板不许崩)。
|
|
79
|
+
// 注意:真正的「会话不活跃」是 404 + JSON { ok:false, error:'session-not-live…' },
|
|
80
|
+
// 走下面的 error 分支照原样显示服务端原因,不会被误判成"接口不存在"。
|
|
81
|
+
if (res && (res.status === 404 || res.status === 405)) {
|
|
82
|
+
return { kind: 'degrade', message: '⚠ 酒馆未提供开场白注入接口(需更新/重启 dsh-tavern-v2)' }
|
|
83
|
+
}
|
|
84
|
+
return { kind: 'error', message: '❌ 注入失败(HTTP ' + ((res && res.status) || '?') + ')' }
|
|
85
|
+
}
|
|
86
|
+
if (d.ok) {
|
|
87
|
+
return { kind: 'ok', message: '✅ 已注入「' + String(d.cardName || '') + '」的开场白(' +
|
|
88
|
+
String(d.greetingLen || 0) + ' 字,第 ' + String(d.turn == null ? '?' : d.turn) + ' 回合)——回到会话刷新可见' }
|
|
89
|
+
}
|
|
90
|
+
// 防重复:会话里已有 source.model==='character-card' 的开场白楼 ⇒ 无事可做,非报错。
|
|
91
|
+
if (d.error === 'greeting-already-present') {
|
|
92
|
+
return { kind: 'present', message: '本会话已有开场白,无需重复注入' }
|
|
93
|
+
}
|
|
94
|
+
return { kind: 'error', message: '❌ ' + String(d.error || '注入失败') }
|
|
95
|
+
}
|
|
96
|
+
// [[card-ops-greeting-end]]
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 只读诊断取值:每条都走同源 HTTP,**单项失败只影响它自己那一行**。
|
|
100
|
+
* 卡脚本接口不可用(引擎没这路由)⇒ 直接不产出该行(不显示假的 0/0)。
|
|
101
|
+
*/
|
|
102
|
+
async function cardOpsLoadStatus(input) {
|
|
103
|
+
const inp = input || {}
|
|
104
|
+
const callFetch = inp.fetch || (typeof fetch === 'function' ? fetch : null)
|
|
105
|
+
const sessionId = String(inp.sessionId || '')
|
|
106
|
+
const cardName = String(inp.cardName || '')
|
|
107
|
+
const presetDir = String(inp.presetDir || '')
|
|
108
|
+
const rows = []
|
|
109
|
+
rows.push({ key: 'card', label: '当前会话绑定的卡', value: cardName || '(未识别,本会话可能未绑卡)' })
|
|
110
|
+
if (callFetch && cardName) {
|
|
111
|
+
try {
|
|
112
|
+
const qs = '?cardName=' + encodeURIComponent(cardName) +
|
|
113
|
+
(presetDir ? '&presetDir=' + encodeURIComponent(presetDir) : '')
|
|
114
|
+
const r = await callFetch('/api/muv-engine/card-scripts' + qs)
|
|
115
|
+
const d = await r.json()
|
|
116
|
+
if (d && d.ok) {
|
|
117
|
+
rows.push({
|
|
118
|
+
key: 'scripts', label: '卡脚本',
|
|
119
|
+
value: Number(d.enabled || 0) + '/' + Number(d.total || 0) + ' 条启用',
|
|
120
|
+
hint: '来源 ' + String(d.source || '?') + (d.fileName ? ' · ' + String(d.fileName) : '') +
|
|
121
|
+
(d.reason ? '\n' + String(d.reason) : '')
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
// !ok ⇒ 引擎没有这个接口:隐藏该行(如实"不显示"好过显示一个假的数字)
|
|
125
|
+
} catch (_) { /* 同上:接口不可用 ⇒ 隐藏该行 */ }
|
|
126
|
+
} else if (callFetch) {
|
|
127
|
+
rows.push({ key: 'scripts', label: '卡脚本', value: '(先识别到卡名才能查)' })
|
|
128
|
+
}
|
|
129
|
+
if (callFetch) {
|
|
130
|
+
try {
|
|
131
|
+
const r = await callFetch('/api/muv-engine/state?sessionId=' + encodeURIComponent(sessionId || 'default'))
|
|
132
|
+
const d = await r.json()
|
|
133
|
+
const tree = d && d.ok && d.state ? d.state.data : null
|
|
134
|
+
if (d && d.ok) {
|
|
135
|
+
rows.push({
|
|
136
|
+
key: 'vars', label: '运行时变量树顶层键',
|
|
137
|
+
value: (tree && typeof tree === 'object') ? Object.keys(tree).length + ' 个' : '0 个(本会话还没有运行时变量)'
|
|
138
|
+
})
|
|
139
|
+
} else {
|
|
140
|
+
rows.push({ key: 'vars', label: '运行时变量树顶层键', value: '不可用(muv-engine 未响应)' })
|
|
141
|
+
}
|
|
142
|
+
} catch (_) {
|
|
143
|
+
rows.push({ key: 'vars', label: '运行时变量树顶层键', value: '不可用(muv-engine 未响应)' })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
rows.push({ key: 'build', label: '客户端构建', value: CARD_OPS_BUILD })
|
|
147
|
+
return rows
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── 🧰 卡运维(React 只是壳,逻辑在上面两个纯函数里)──────────────────
|
|
151
|
+
// 交互风格沿用本视图既有写法:内联 style + C 里的 token,不引入任何新框架。
|
|
152
|
+
function CardOpsPanel({ sessionId, cardName, presetDir }) {
|
|
153
|
+
const [open, setOpen] = React.useState(false)
|
|
154
|
+
const [rows, setRows] = React.useState([])
|
|
155
|
+
const [busy, setBusy] = React.useState(false)
|
|
156
|
+
const [greet, setGreet] = React.useState(null) // { kind, message }
|
|
157
|
+
const [statusBusy, setStatusBusy] = React.useState(false)
|
|
158
|
+
|
|
159
|
+
const boundFetch = () => (typeof fetch === 'function' ? fetch.bind(window) : null)
|
|
160
|
+
|
|
161
|
+
const refresh = async () => {
|
|
162
|
+
setStatusBusy(true)
|
|
163
|
+
try {
|
|
164
|
+
setRows(await cardOpsLoadStatus({ sessionId, cardName, presetDir, fetch: boundFetch() }))
|
|
165
|
+
} finally { setStatusBusy(false) }
|
|
166
|
+
}
|
|
167
|
+
// 展开即刷新一次(收起时不打扰)
|
|
168
|
+
React.useEffect(() => { if (open) refresh() }, [open, sessionId, cardName, presetDir])
|
|
169
|
+
|
|
170
|
+
const doInsertGreeting = async () => {
|
|
171
|
+
setBusy(true)
|
|
172
|
+
setGreet({ kind: 'busy', message: '⏳ 注入中…' })
|
|
173
|
+
try {
|
|
174
|
+
setGreet(await cardOpsInsertGreeting({ sessionId, fetch: boundFetch() }))
|
|
175
|
+
} catch (e) {
|
|
176
|
+
// 兜底:纯函数自身吞异常,这里只是"再保险",绝不让异常冒到 React 渲染里
|
|
177
|
+
setGreet({ kind: 'error', message: '❌ ' + ((e && e.message) || String(e)) })
|
|
178
|
+
} finally { setBusy(false) }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ★ 第 40 轮:这四个状态色原来是写死的(#27ae60 / #3498db / #e67e22 / #e74c3c)。
|
|
182
|
+
// 深色模式下红/绿压在深底上偏暗;改成 DSH 的主题状态色(回退值 = 原值),
|
|
183
|
+
// 两个主题都跟宿主,浅色下观感不变。
|
|
184
|
+
const GC = { ok: 'var(--dsw-alias-state-success-primary,#27ae60)', present: 'var(--dsw-alias-state-business-primary,#3498db)', degrade: 'var(--dsw-alias-state-warn-primary,#e67e22)', error: 'var(--dsw-alias-state-error-primary,#e74c3c)', busy: C.dim }
|
|
185
|
+
const GBG = { ok: 'rgba(39,174,96,0.10)', present: 'rgba(52,152,219,0.12)', degrade: 'rgba(230,126,34,0.10)', error: 'rgba(231,76,60,0.10)', busy: 'transparent' }
|
|
186
|
+
const btn = { padding: '5px 12px', borderRadius: 6, border: '1px solid ' + C.border, background: C.bg3, color: C.text, cursor: 'pointer', fontSize: 11, fontFamily: 'inherit' }
|
|
187
|
+
|
|
188
|
+
return jsx.jsxs('div', {
|
|
189
|
+
'data-dsh-muv-card-ops': '',
|
|
190
|
+
style: { flexShrink: 0, borderBottom: '1px solid ' + C.border, background: C.bg2 },
|
|
191
|
+
children: [
|
|
192
|
+
// 标题行(点击展开/收起)
|
|
193
|
+
jsx.jsxs('div', {
|
|
194
|
+
'data-dsh-card-ops-toggle': '',
|
|
195
|
+
onClick: () => setOpen(o => !o),
|
|
196
|
+
style: { display: 'flex', alignItems: 'center', gap: 8, padding: '7px 14px', cursor: 'pointer', userSelect: 'none' },
|
|
197
|
+
onMouseEnter: e => { e.currentTarget.style.background = C.hover },
|
|
198
|
+
onMouseLeave: e => { e.currentTarget.style.background = 'transparent' },
|
|
199
|
+
children: [
|
|
200
|
+
jsx.jsx('span', { style: { fontSize: 10, color: C.dim, width: 12 }, children: open ? '▾' : '▸' }),
|
|
201
|
+
jsx.jsx('span', { style: { fontWeight: 600, fontSize: 12, flex: 1 }, children: '🧰 卡运维' }),
|
|
202
|
+
jsx.jsx('span', { style: { fontSize: 10, color: C.dim }, children: '开场白 · 卡与脚本状态' })
|
|
203
|
+
]
|
|
204
|
+
}),
|
|
205
|
+
open && jsx.jsxs('div', { style: { padding: '0 14px 12px', display: 'flex', flexDirection: 'column', gap: 10 }, children: [
|
|
206
|
+
// ── 📌 开场白 ──
|
|
207
|
+
jsx.jsxs('div', { children: [
|
|
208
|
+
jsx.jsx('div', { style: { fontSize: 11, color: C.dim, marginBottom: 6 }, children: '📌 开场白' }),
|
|
209
|
+
jsx.jsx('button', {
|
|
210
|
+
'data-dsh-card-ops-greeting-insert': '',
|
|
211
|
+
type: 'button', disabled: busy, onClick: doInsertGreeting,
|
|
212
|
+
title: '把当前卡的开场白注入当前会话末尾(旧会话也能用)\n请求只带 sessionId,预设由服务端按会话权威解析',
|
|
213
|
+
style: { ...btn, opacity: busy ? 0.6 : 1, cursor: busy ? 'default' : 'pointer' },
|
|
214
|
+
children: '➕ 把当前卡的开场白注入会话末尾'
|
|
215
|
+
}),
|
|
216
|
+
jsx.jsx('div', {
|
|
217
|
+
'data-dsh-card-ops-greeting-status': '',
|
|
218
|
+
style: {
|
|
219
|
+
marginTop: 6, padding: '5px 10px', borderRadius: 6, fontSize: 11, lineHeight: 1.6,
|
|
220
|
+
color: GC[greet ? greet.kind : 'busy'] || C.dim,
|
|
221
|
+
background: greet ? (GBG[greet.kind] || 'transparent') : 'transparent'
|
|
222
|
+
},
|
|
223
|
+
children: greet ? greet.message : '未注入(本会话需要开场白时点上面的按钮)'
|
|
224
|
+
})
|
|
225
|
+
] }),
|
|
226
|
+
// ── 🧩 卡与脚本状态(只读诊断)──
|
|
227
|
+
jsx.jsxs('div', { children: [
|
|
228
|
+
jsx.jsxs('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }, children: [
|
|
229
|
+
jsx.jsx('div', { style: { fontSize: 11, color: C.dim, flex: 1 }, children: '🧩 卡与脚本状态(只读)' }),
|
|
230
|
+
jsx.jsx('button', {
|
|
231
|
+
'data-dsh-card-ops-refresh': '', type: 'button', disabled: statusBusy, onClick: refresh,
|
|
232
|
+
style: { ...btn, fontSize: 10, padding: '2px 8px', opacity: statusBusy ? 0.6 : 1 },
|
|
233
|
+
children: statusBusy ? '⏳ 刷新中' : '🔄 刷新'
|
|
234
|
+
})
|
|
235
|
+
] }),
|
|
236
|
+
jsx.jsx('div', {
|
|
237
|
+
'data-dsh-card-ops-rows': '',
|
|
238
|
+
style: { border: '1px solid ' + C.border, borderRadius: 8, background: C.bg1, overflow: 'hidden' },
|
|
239
|
+
children: rows.length === 0
|
|
240
|
+
? jsx.jsx('div', { style: { padding: '6px 10px', fontSize: 11, color: C.dim }, children: '(点「🔄 刷新」读取)' })
|
|
241
|
+
: rows.map(r => jsx.jsxs('div', {
|
|
242
|
+
key: r.key,
|
|
243
|
+
style: { display: 'flex', alignItems: 'flex-start', gap: 8, padding: '5px 10px', borderBottom: '1px solid ' + C.borderL },
|
|
244
|
+
children: [
|
|
245
|
+
jsx.jsx('span', { style: { fontSize: 11, color: C.sub, flexShrink: 0, width: 150 }, children: r.label }),
|
|
246
|
+
jsx.jsx('span', {
|
|
247
|
+
title: r.hint || undefined,
|
|
248
|
+
style: { fontSize: 11, color: C.text, wordBreak: 'break-all', cursor: r.hint ? 'help' : 'default' },
|
|
249
|
+
children: r.value
|
|
250
|
+
})
|
|
251
|
+
]
|
|
252
|
+
}, r.key))
|
|
253
|
+
})
|
|
254
|
+
] })
|
|
255
|
+
] })
|
|
256
|
+
]
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ============ 📊 运行时变量(P1 自 dsh-tavern-v2「🔢 变量面板」整体迁入)============
|
|
261
|
+
//
|
|
262
|
+
// 数据源不变,全是 muv-engine 的同源 HTTP(本仓库不改服务端):
|
|
263
|
+
// GET /api/muv-engine/state?sessionId=… → { ok, state:{ data:<树> } }
|
|
264
|
+
// POST /api/muv-engine/state { sessionId, merge:true, data:{…} } 深合并写回(只 merge 不删除)
|
|
265
|
+
// GET /api/muv-engine/state?sessionId=…&snapshots=1 → { ok, snapshots:[{ key, at, data }] }
|
|
266
|
+
// GET /api/muv-engine/state?sessionId=…&messageKey=… → 按楼回看(入口探测失败 ⇒ 隐藏)
|
|
267
|
+
//
|
|
268
|
+
// 防双滚动:新区嵌在 MUV 面板既有的滚动容器里(内容区 flex:1 + 自动滚动那层),
|
|
269
|
+
// 自己不自带任何滚动样式 —— 旧面板 max-height:340px 内滚的双滚动条问题就此消灭。
|
|
270
|
+
//
|
|
271
|
+
// ★ 下面这段纯函数被 test-var-section.mjs **逐字提取执行**:禁止引用 React / DOM /
|
|
272
|
+
// 外层闭包变量,只用入参 + 全局 fetch;两端标记行保持原样。
|
|
273
|
+
// [[var-section-core-start]]
|
|
274
|
+
const VAR_PATH_SEP = '\u0001'
|
|
275
|
+
|
|
276
|
+
function varSectionTypeOf(v) {
|
|
277
|
+
if (v === null) return 'null'
|
|
278
|
+
if (Array.isArray(v)) return 'array'
|
|
279
|
+
return typeof v
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function varSectionPathLabel(arr) { return arr.map(function (k) { return String(k) }).join('.') }
|
|
283
|
+
|
|
284
|
+
// 按路径数组在树里找原始值(找不到 → undefined)
|
|
285
|
+
function varSectionValueAt(root, arr) {
|
|
286
|
+
var cur = root
|
|
287
|
+
for (var i = 0; i < arr.length; i++) {
|
|
288
|
+
if (!cur || typeof cur !== 'object' || !(arr[i] in cur)) return undefined
|
|
289
|
+
cur = cur[arr[i]]
|
|
290
|
+
}
|
|
291
|
+
return cur
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 「已覆盖」三态:initvarData 无此键 → false;值相同 → false;值(类型或值)不同 → true
|
|
295
|
+
function varSectionIsOverridden(initialTree, arr, value) {
|
|
296
|
+
if (!initialTree || typeof initialTree !== 'object') return false
|
|
297
|
+
var iv = varSectionValueAt(initialTree, arr)
|
|
298
|
+
if (iv === undefined) return false
|
|
299
|
+
return JSON.stringify(iv) !== JSON.stringify(value)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 叶子展示信息:null / 数组不可编辑,只显示文本(文本是原样字符串,由上层以
|
|
303
|
+
// children=textContent 方式渲染,任何标签都不会变成真实元素)
|
|
304
|
+
function varSectionLeafDisplay(value) {
|
|
305
|
+
var t = varSectionTypeOf(value)
|
|
306
|
+
if (t === 'null') return { editable: false, text: 'null' }
|
|
307
|
+
if (t === 'array') return { editable: false, text: '[数组 ' + value.length + ' 项]' }
|
|
308
|
+
return { editable: true, type: t, text: String(value) }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 编辑还原原始类型(找不到原叶型按字符串存;number 转不回去就保留字符串)
|
|
312
|
+
function varSectionNormalizeEdit(origType, raw) {
|
|
313
|
+
if (origType === 'boolean') return !!raw
|
|
314
|
+
if (origType === 'number') { var n = Number(raw); return isNaN(n) ? String(raw) : n }
|
|
315
|
+
return String(raw)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// edits 表 → 嵌套 payload(merge 写回用)。值**原样**提交:null / 0 / false / ''
|
|
319
|
+
// 都不做任何 coerce —— merge 语义会不会吃掉它由提交形状保证。
|
|
320
|
+
function varSectionEditsToPayload(edits) {
|
|
321
|
+
var payload = {}
|
|
322
|
+
for (var pk in (edits || {})) {
|
|
323
|
+
var arr = pk.split(VAR_PATH_SEP)
|
|
324
|
+
var cur = payload
|
|
325
|
+
for (var i = 0; i < arr.length - 1; i++) {
|
|
326
|
+
if (!cur[arr[i]] || typeof cur[arr[i]] !== 'object') cur[arr[i]] = {}
|
|
327
|
+
cur = cur[arr[i]]
|
|
328
|
+
}
|
|
329
|
+
cur[arr[arr.length - 1]] = edits[pk].value
|
|
330
|
+
}
|
|
331
|
+
return payload
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 保存结果 → 新 edits 表:失败/无会话**保留**(本地修改可重试),成功才清空
|
|
335
|
+
function varSectionApplySaveResult(prevEdits, result) {
|
|
336
|
+
if (result && result.kind === 'ok') return {}
|
|
337
|
+
return prevEdits
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 首次读取默认展开:只展开**顶层**的对象分支(懒展开 —— 深层点开才渲染)
|
|
341
|
+
function varSectionDefaultExpanded(tree) {
|
|
342
|
+
var expanded = {}
|
|
343
|
+
if (!tree || typeof tree !== 'object') return expanded
|
|
344
|
+
for (var tk in tree) {
|
|
345
|
+
var tv = tree[tk]
|
|
346
|
+
if (tv !== null && typeof tv === 'object' && !Array.isArray(tv) && Object.keys(tv).length > 0) {
|
|
347
|
+
expanded[tk] = true
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return expanded
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 搜索:命中路径/值的叶子拍平(限 200 条防炸);无命中 → []
|
|
354
|
+
function varSectionSearchHits(tree, query, limit) {
|
|
355
|
+
var q = String(query || '').trim().toLowerCase()
|
|
356
|
+
var hits = []
|
|
357
|
+
var cap = limit || 200
|
|
358
|
+
if (!q) return hits
|
|
359
|
+
function walk(node, arr) {
|
|
360
|
+
if (hits.length >= cap) return
|
|
361
|
+
if (!node || typeof node !== 'object') return
|
|
362
|
+
for (var k in node) {
|
|
363
|
+
var v = node[k]
|
|
364
|
+
var arr2 = arr.concat([k])
|
|
365
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) walk(v, arr2)
|
|
366
|
+
else {
|
|
367
|
+
var hay = (varSectionPathLabel(arr2) + ' ' + String(v)).toLowerCase()
|
|
368
|
+
if (hay.indexOf(q) >= 0) hits.push({ path: arr2, value: v })
|
|
369
|
+
if (hits.length >= cap) return
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
walk(tree, [])
|
|
374
|
+
return hits
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function varSectionLoadState(input) {
|
|
378
|
+
var inp = input || {}
|
|
379
|
+
var callFetch = inp.fetch || (typeof fetch === 'function' ? fetch : null)
|
|
380
|
+
var sessionId = String(inp.sessionId || '')
|
|
381
|
+
if (!sessionId) return { kind: 'no-session', message: '未检测到当前会话 —— 打开一个对话后再看变量', tree: null }
|
|
382
|
+
if (!callFetch) return { kind: 'error', message: '当前环境不支持 fetch', tree: null }
|
|
383
|
+
try {
|
|
384
|
+
var r = await callFetch('/api/muv-engine/state?sessionId=' + encodeURIComponent(sessionId))
|
|
385
|
+
var d = await r.json()
|
|
386
|
+
if (!d || !d.ok) throw new Error((d && d.error) || '读取失败')
|
|
387
|
+
var tree = (d.state && d.state.data) || null
|
|
388
|
+
return { kind: 'ok', tree: (tree && typeof tree === 'object') ? tree : null }
|
|
389
|
+
} catch (e) {
|
|
390
|
+
return { kind: 'error', message: '读取变量失败:' + ((e && e.message) || String(e)), tree: null }
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function varSectionSave(input) {
|
|
395
|
+
var inp = input || {}
|
|
396
|
+
var callFetch = inp.fetch || (typeof fetch === 'function' ? fetch : null)
|
|
397
|
+
var sessionId = String(inp.sessionId || '')
|
|
398
|
+
var edits = inp.edits || {}
|
|
399
|
+
var n = Object.keys(edits).length
|
|
400
|
+
if (!sessionId) return { kind: 'no-session', message: '未检测到当前会话,写不回去', keepEdits: true }
|
|
401
|
+
if (!n) return { kind: 'nothing', message: '没有未保存的修改', keepEdits: true }
|
|
402
|
+
if (!callFetch) return { kind: 'error', message: '当前环境不支持 fetch(本地修改已保留,可重试)', keepEdits: true }
|
|
403
|
+
try {
|
|
404
|
+
var r = await callFetch('/api/muv-engine/state', {
|
|
405
|
+
method: 'POST',
|
|
406
|
+
headers: { 'content-type': 'application/json' },
|
|
407
|
+
body: JSON.stringify({ sessionId: sessionId, merge: true, data: varSectionEditsToPayload(edits) })
|
|
408
|
+
})
|
|
409
|
+
var d = await r.json()
|
|
410
|
+
if (!d || !d.ok) throw new Error((d && d.error) || '写回失败')
|
|
411
|
+
return { kind: 'ok', count: n, message: '已写回 ' + n + ' 处(merge)—— 卡下一轮查询/推送就能拿到新值' }
|
|
412
|
+
} catch (e) {
|
|
413
|
+
return { kind: 'error', message: ((e && e.message) || String(e)) + '(本地修改已保留,可重试)', keepEdits: true }
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ⏱ 快照列表探测:任何失败(404 / 网络错 / 快照为空)→ kind:'hidden',入口整行隐藏
|
|
418
|
+
async function varSectionListSnapshots(input) {
|
|
419
|
+
var inp = input || {}
|
|
420
|
+
var callFetch = inp.fetch || (typeof fetch === 'function' ? fetch : null)
|
|
421
|
+
var sessionId = String(inp.sessionId || '')
|
|
422
|
+
if (!sessionId || !callFetch) return { kind: 'hidden', message: '快照不可用' }
|
|
423
|
+
try {
|
|
424
|
+
var r = await callFetch('/api/muv-engine/state?sessionId=' + encodeURIComponent(sessionId) + '&snapshots=1')
|
|
425
|
+
var d = await r.json()
|
|
426
|
+
var list = (d && d.ok && Array.isArray(d.snapshots)) ? d.snapshots : null
|
|
427
|
+
if (!list || !list.length) return { kind: 'hidden', message: '无快照' }
|
|
428
|
+
return { kind: 'ok', snapshots: list }
|
|
429
|
+
} catch (_) {
|
|
430
|
+
return { kind: 'hidden', message: '快照接口不可用' } // 探测失败 → 静默隐藏,不报错不假装有数据
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ⏱ 进入某楼回看:messageKey 拉那层的树(响应形状与普通 GET 一致);失败如实报错。
|
|
435
|
+
// 回看视图由调用方进入只读模式 —— 快照绝不 merge 写回。
|
|
436
|
+
async function varSectionEnterSnapshot(input) {
|
|
437
|
+
var inp = input || {}
|
|
438
|
+
var callFetch = inp.fetch || (typeof fetch === 'function' ? fetch : null)
|
|
439
|
+
var sessionId = String(inp.sessionId || '')
|
|
440
|
+
var messageKey = String(inp.messageKey || '')
|
|
441
|
+
if (!sessionId || !messageKey || !callFetch) return { kind: 'error', message: '该楼快照不存在' }
|
|
442
|
+
try {
|
|
443
|
+
var r = await callFetch('/api/muv-engine/state?sessionId=' + encodeURIComponent(sessionId) + '&messageKey=' + encodeURIComponent(messageKey))
|
|
444
|
+
var d = await r.json()
|
|
445
|
+
if (!d || !d.ok || !d.state) throw new Error('该楼快照不存在')
|
|
446
|
+
return { kind: 'ok', tree: (d.state && d.state.data) || null }
|
|
447
|
+
} catch (e) {
|
|
448
|
+
return { kind: 'error', message: '回看失败:' + ((e && e.message) || String(e)) }
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 快照下拉项的展示标签(时间 + 键尾 8 位;过大未存的禁选提示)
|
|
453
|
+
function varSectionSnapLabel(s) {
|
|
454
|
+
s = s || {}
|
|
455
|
+
var key = String(s.key || '')
|
|
456
|
+
var n = Number(s.at)
|
|
457
|
+
var when = '未知时间'
|
|
458
|
+
if (n > 0) {
|
|
459
|
+
var d = new Date(n)
|
|
460
|
+
var p2 = function (x) { return (x < 10 ? '0' : '') + x }
|
|
461
|
+
when = d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()) + ' ' + p2(d.getHours()) + ':' + p2(d.getMinutes())
|
|
462
|
+
}
|
|
463
|
+
var tail = key.length > 8 ? key.slice(-8) : key
|
|
464
|
+
return when + ' · …' + tail + (s.data == null ? '(过大未存)' : '')
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 导出文本:没有树 → null(调用方提示"还没有变量可导出")
|
|
468
|
+
function varSectionExportText(tree) {
|
|
469
|
+
if (!tree || typeof tree !== 'object') return null
|
|
470
|
+
return JSON.stringify(tree, null, 2)
|
|
471
|
+
}
|
|
472
|
+
// [[var-section-core-end]]
|
|
473
|
+
|
|
474
|
+
// ── 📊 运行时变量(React 只是壳,逻辑在上面纯函数里)──────────────────
|
|
475
|
+
// XSS 纪律:变量值是外部数据,一律走 children(= textContent)渲染,
|
|
476
|
+
// 本区不做任何 HTML 字符串拼接。
|
|
477
|
+
function RuntimeVarsSection({ sessionId, initvarData, reloadToken }) {
|
|
478
|
+
const [open, setOpen] = React.useState(false) // 默认折叠,点标题展开/收起
|
|
479
|
+
const [tree, setTree] = React.useState(null)
|
|
480
|
+
const [edits, setEdits] = React.useState({}) // pathKey -> { value, type }(未保存的本地修改)
|
|
481
|
+
const [expanded, setExpanded] = React.useState({}) // pathKey -> true(懒展开:点开才渲染子层)
|
|
482
|
+
const [query, setQuery] = React.useState('')
|
|
483
|
+
const [snapList, setSnapList] = React.useState(null) // null ⇒ 快照入口整行隐藏
|
|
484
|
+
const [snapMode, setSnapMode] = React.useState(false) // ⏱ 回看模式:整树只读,绝不写回
|
|
485
|
+
const [status, setStatus] = React.useState(null) // { text, tone }
|
|
486
|
+
|
|
487
|
+
const boundFetch = () => (typeof fetch === 'function' ? fetch.bind(window) : null)
|
|
488
|
+
const TC = {
|
|
489
|
+
ok: 'var(--dsw-alias-state-success-primary,#27ae60)',
|
|
490
|
+
busy: 'var(--dsw-alias-state-business-primary,#3498db)',
|
|
491
|
+
warn: 'var(--dsw-alias-state-warn-primary,#e67e22)',
|
|
492
|
+
error: 'var(--dsw-alias-state-error-primary,#e74c3c)',
|
|
493
|
+
dim: C.dim
|
|
494
|
+
}
|
|
495
|
+
const btnSm = { padding: '2px 8px', borderRadius: 6, border: '1px solid ' + C.border, background: C.bg3, color: C.text, cursor: 'pointer', fontSize: 11, fontFamily: 'inherit' }
|
|
496
|
+
|
|
497
|
+
const doLoad = async (quiet) => {
|
|
498
|
+
if (!quiet) setStatus({ text: '⏳ 读取变量中…', tone: 'busy' })
|
|
499
|
+
const r = await varSectionLoadState({ sessionId, fetch: boundFetch() })
|
|
500
|
+
if (r.kind === 'ok') {
|
|
501
|
+
setTree(r.tree)
|
|
502
|
+
setExpanded(varSectionDefaultExpanded(r.tree))
|
|
503
|
+
const top = r.tree ? Object.keys(r.tree).length : 0
|
|
504
|
+
const pending = Object.keys(edits).length
|
|
505
|
+
setStatus({ text: '✅ 已读取' + (top ? '(顶层 ' + top + ' 个分支)' : '(空树)') + (pending ? ' · 有 ' + pending + ' 处未保存修改' : ''), tone: 'ok' })
|
|
506
|
+
// ⏱ 静默探测快照入口:探测失败 ⇒ snapList 保持 null,整行隐藏
|
|
507
|
+
const sr = await varSectionListSnapshots({ sessionId, fetch: boundFetch() })
|
|
508
|
+
setSnapList(sr.kind === 'ok' ? sr.snapshots : null)
|
|
509
|
+
} else {
|
|
510
|
+
setTree(null)
|
|
511
|
+
setStatus({ text: (r.kind === 'no-session' ? '⚠️ ' : '❌ ') + r.message, tone: r.kind === 'no-session' ? 'warn' : 'error' })
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// 会话/预设切换不另挂监听:MuvTableView 的 check() 消费同一批 forceReload
|
|
516
|
+
// 监听后 bump reloadToken,这里跟着重读;sessionId 变了 ⇒ 旧会话的本地
|
|
517
|
+
// edits / 回看状态一并丢弃(避免把 A 会话的修改 merge 进 B 会话)。
|
|
518
|
+
const prevSid = React.useRef(sessionId)
|
|
519
|
+
React.useEffect(() => {
|
|
520
|
+
if (!open) return
|
|
521
|
+
if (prevSid.current !== sessionId) { prevSid.current = sessionId; setEdits({}); setSnapMode(false); setQuery('') }
|
|
522
|
+
doLoad(false)
|
|
523
|
+
}, [open, sessionId, reloadToken])
|
|
524
|
+
|
|
525
|
+
// 🔄 刷新:丢弃本地修改 + 退出回看,重新从服务端读
|
|
526
|
+
const doRefresh = () => { setEdits({}); setSnapMode(false); setQuery(''); doLoad(false) }
|
|
527
|
+
|
|
528
|
+
const doSave = async () => {
|
|
529
|
+
if (snapMode) { setStatus({ text: '⛔ 正在回看历史楼层的变量(只读)—— 点「回到最新」退出后再修改', tone: 'warn' }); return }
|
|
530
|
+
const n = Object.keys(edits).length
|
|
531
|
+
setStatus({ text: n ? '⏳ 写回 ' + n + ' 处修改…' : '⏳ …', tone: 'busy' })
|
|
532
|
+
const r = await varSectionSave({ sessionId, edits, fetch: boundFetch() })
|
|
533
|
+
setEdits(prev => varSectionApplySaveResult(prev, r))
|
|
534
|
+
if (r.kind === 'ok') {
|
|
535
|
+
setStatus({ text: '✅ ' + r.message, tone: 'ok' })
|
|
536
|
+
doLoad(true)
|
|
537
|
+
} else {
|
|
538
|
+
setStatus({ text: (r.kind === 'error' ? '❌ ' : r.kind === 'no-session' ? '⚠️ ' : '') + r.message, tone: r.kind === 'error' ? 'error' : 'warn' })
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const doExport = async () => {
|
|
543
|
+
const text = varSectionExportText(tree)
|
|
544
|
+
if (!text) { setStatus({ text: '还没有变量可导出', tone: 'warn' }); return }
|
|
545
|
+
try {
|
|
546
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
547
|
+
await navigator.clipboard.writeText(text)
|
|
548
|
+
setStatus({ text: '✅ 已复制到剪贴板(' + text.length + ' 字符)', tone: 'ok' })
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
throw new Error('clipboard-unavailable')
|
|
552
|
+
} catch (_) {
|
|
553
|
+
try {
|
|
554
|
+
const ta = document.createElement('textarea')
|
|
555
|
+
ta.value = text; document.body.appendChild(ta); ta.select()
|
|
556
|
+
document.execCommand('copy')
|
|
557
|
+
document.body.removeChild(ta)
|
|
558
|
+
setStatus({ text: '✅ 已复制到剪贴板(' + text.length + ' 字符)', tone: 'ok' })
|
|
559
|
+
} catch (_e) { setStatus({ text: '❌ 复制失败,浏览器拒绝了剪贴板访问', tone: 'error' }) }
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const enterSnap = async (key) => {
|
|
564
|
+
if (!key) { exitSnap(); return }
|
|
565
|
+
setStatus({ text: '⏳ 正在拉取该楼快照…', tone: 'busy' })
|
|
566
|
+
const r = await varSectionEnterSnapshot({ sessionId, messageKey: key, fetch: boundFetch() })
|
|
567
|
+
if (r.kind === 'ok') {
|
|
568
|
+
setSnapMode(true); setEdits({}); setQuery('')
|
|
569
|
+
setTree(r.tree)
|
|
570
|
+
setExpanded(varSectionDefaultExpanded(r.tree))
|
|
571
|
+
setStatus({ text: '⏱ 正在回看历史楼层的变量,点「回到最新」恢复', tone: 'busy' })
|
|
572
|
+
} else {
|
|
573
|
+
setStatus({ text: '❌ ' + r.message, tone: 'error' })
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const exitSnap = () => { setSnapMode(false); doLoad(false) }
|
|
577
|
+
|
|
578
|
+
const setEdit = (pk, origType, raw) => setEdits(prev => ({
|
|
579
|
+
...prev,
|
|
580
|
+
[pk]: { value: varSectionNormalizeEdit(origType, raw), type: ['boolean', 'number', 'string'].indexOf(origType) >= 0 ? origType : 'string' }
|
|
581
|
+
}))
|
|
582
|
+
|
|
583
|
+
const inputStyle = { flex: 1, minWidth: 120, padding: '2px 6px', fontSize: 12, background: C.bg3, color: C.text, border: '1px solid ' + C.border, borderRadius: 4, fontFamily: 'inherit', outline: 'none' }
|
|
584
|
+
|
|
585
|
+
const renderLeaf = (arr, value) => {
|
|
586
|
+
const pk = arr.join(VAR_PATH_SEP)
|
|
587
|
+
const edited = edits[pk]
|
|
588
|
+
// ★ 已编辑的叶子回显**编辑后的值**(否则重渲染时输入框被重置回旧值);
|
|
589
|
+
// 「已覆盖」对照的是**服务端值**,回看模式下不标(对照当前卡 initvar 对历史楼层语义错位)
|
|
590
|
+
const displayValue = edited !== undefined ? edited.value : value
|
|
591
|
+
const displayType = varSectionTypeOf(displayValue)
|
|
592
|
+
const overridden = !snapMode && varSectionIsOverridden(initvarData, arr, value)
|
|
593
|
+
const info = varSectionLeafDisplay(displayValue)
|
|
594
|
+
const indent = Math.min(arr.length - 1, 6) * 14 + 4
|
|
595
|
+
const editor = !info.editable
|
|
596
|
+
? jsx.jsx('span', { style: { fontSize: 11, color: C.dim }, children: info.text })
|
|
597
|
+
: displayType === 'boolean'
|
|
598
|
+
? jsx.jsxs('label', { style: { cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }, children: [
|
|
599
|
+
jsx.jsx('input', { type: 'checkbox', checked: !!displayValue, onChange: e => setEdit(pk, 'boolean', e.target.checked) }),
|
|
600
|
+
displayValue ? 'true' : 'false'
|
|
601
|
+
] })
|
|
602
|
+
: jsx.jsx('input', { type: 'text', value: String(displayValue), onChange: e => setEdit(pk, varSectionTypeOf(value), e.target.value), style: inputStyle })
|
|
603
|
+
return jsx.jsxs('div', {
|
|
604
|
+
'data-dsh-muv-var-leaf': pk,
|
|
605
|
+
style: { display: 'flex', alignItems: 'center', gap: 6, padding: '1px 0 1px ' + indent + 'px' },
|
|
606
|
+
children: [
|
|
607
|
+
jsx.jsx('span', { style: { color: C.accent, fontSize: 12 }, children: String(arr[arr.length - 1]) }),
|
|
608
|
+
overridden && jsx.jsx('span', { title: '与卡声明的初始值不同', style: { fontSize: 10, color: TC.warn }, children: '已覆盖' }),
|
|
609
|
+
edited !== undefined && jsx.jsx('span', { style: { fontSize: 10, color: TC.ok }, children: '●未保存' }),
|
|
610
|
+
jsx.jsx('span', { style: { flex: 1 } }),
|
|
611
|
+
jsx.jsx('span', { style: { flex: 2, display: 'flex', alignItems: 'center', gap: 6 }, children: editor })
|
|
612
|
+
]
|
|
613
|
+
}, pk)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// 渲染一棵子树:只渲染到已展开层级(懒展开,不一次性渲染上千节点)
|
|
617
|
+
const renderNode = (node, arr) => {
|
|
618
|
+
const out = []
|
|
619
|
+
const keys = Object.keys(node || {}).sort((a, b) => {
|
|
620
|
+
const ao = node[a] !== null && typeof node[a] === 'object' && !Array.isArray(node[a])
|
|
621
|
+
const bo = node[b] !== null && typeof node[b] === 'object' && !Array.isArray(node[b])
|
|
622
|
+
if (ao !== bo) return ao ? 1 : -1 // 叶子在前,对象在后(与旧面板一致)
|
|
623
|
+
return a < b ? -1 : a > b ? 1 : 0
|
|
624
|
+
})
|
|
625
|
+
for (let i = 0; i < keys.length; i++) {
|
|
626
|
+
const k = keys[i]
|
|
627
|
+
const v = node[k]
|
|
628
|
+
const arr2 = arr.concat([k])
|
|
629
|
+
const t = varSectionTypeOf(v)
|
|
630
|
+
const pk = arr2.join(VAR_PATH_SEP)
|
|
631
|
+
if (t === 'object' && Object.keys(v).length > 0) {
|
|
632
|
+
const isOpen = !!expanded[pk]
|
|
633
|
+
out.push(jsx.jsxs('div', {
|
|
634
|
+
'data-dsh-muv-var-node': pk,
|
|
635
|
+
onClick: () => setExpanded(prev => ({ ...prev, [pk]: !prev[pk] })),
|
|
636
|
+
style: { cursor: 'pointer', userSelect: 'none', padding: '1px 0 1px ' + (Math.min(arr.length - 1, 6) * 14) + 'px' },
|
|
637
|
+
children: [
|
|
638
|
+
jsx.jsx('span', { style: { color: C.dim, fontSize: 10 }, children: isOpen ? '▼' : '▶' }),
|
|
639
|
+
jsx.jsx('span', { style: { color: 'var(--dsw-alias-state-business-primary,#3498db)', fontSize: 12 }, children: String(k) }),
|
|
640
|
+
jsx.jsx('span', { style: { fontSize: 10, color: C.dim }, children: ' {' + Object.keys(v).length + '}' })
|
|
641
|
+
]
|
|
642
|
+
}, pk))
|
|
643
|
+
if (isOpen) out.push(jsx.jsx('div', { children: renderNode(v, arr2) }, pk + '::sub'))
|
|
644
|
+
} else if (t === 'object' || (t === 'array' && v.length === 0)) {
|
|
645
|
+
out.push(renderLeaf(arr2, t === 'object' ? {} : v))
|
|
646
|
+
} else {
|
|
647
|
+
out.push(renderLeaf(arr2, v))
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return out
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const q = query.trim().toLowerCase()
|
|
654
|
+
const searchHits = (q && tree && typeof tree === 'object') ? varSectionSearchHits(tree, q) : null
|
|
655
|
+
const treeBody = !tree || typeof tree !== 'object'
|
|
656
|
+
? jsx.jsx('div', { style: { color: C.dim, padding: 8 }, children: '(当前会话还没有变量 —— 先和卡聊一轮,或导入带 initvar 的卡)' })
|
|
657
|
+
: searchHits
|
|
658
|
+
? (searchHits.length === 0
|
|
659
|
+
? jsx.jsx('div', { style: { color: C.dim, padding: 8 }, children: '没有匹配的变量' })
|
|
660
|
+
: jsx.jsxs('div', { children: [
|
|
661
|
+
jsx.jsx('div', { style: { fontSize: 10, color: C.dim, marginBottom: 4 }, children: '匹配 ' + searchHits.length + ' 条叶子' + (searchHits.length >= 200 ? '(已截断)' : '') + ':' }),
|
|
662
|
+
searchHits.map(h => jsx.jsxs('div', { key: 'hit::' + varSectionPathLabel(h.path), children: [
|
|
663
|
+
jsx.jsx('div', { style: { fontSize: 10, color: C.dim, wordBreak: 'break-all' }, children: varSectionPathLabel(h.path) }),
|
|
664
|
+
renderLeaf(h.path, h.value)
|
|
665
|
+
] }))
|
|
666
|
+
] }))
|
|
667
|
+
: renderNode(tree, [])
|
|
668
|
+
|
|
669
|
+
return jsx.jsxs('div', {
|
|
670
|
+
'data-dsh-muv-var-section': '',
|
|
671
|
+
style: { flexShrink: 0, borderBottom: '1px solid ' + C.border, background: C.bg2 },
|
|
672
|
+
children: [
|
|
673
|
+
// 标题行(点击展开/收起,默认折叠)
|
|
674
|
+
jsx.jsxs('div', {
|
|
675
|
+
'data-dsh-muv-var-toggle': '',
|
|
676
|
+
onClick: () => setOpen(o => !o),
|
|
677
|
+
style: { display: 'flex', alignItems: 'center', gap: 8, padding: '7px 14px', cursor: 'pointer', userSelect: 'none' },
|
|
678
|
+
onMouseEnter: e => { e.currentTarget.style.background = C.hover },
|
|
679
|
+
onMouseLeave: e => { e.currentTarget.style.background = 'transparent' },
|
|
680
|
+
children: [
|
|
681
|
+
jsx.jsx('span', { style: { fontSize: 10, color: C.dim, width: 12 }, children: open ? '▾' : '▸' }),
|
|
682
|
+
jsx.jsx('span', { style: { fontWeight: 600, fontSize: 12, flex: 1 }, children: '📊 运行时变量' }),
|
|
683
|
+
jsx.jsx('span', { style: { fontSize: 10, color: C.dim }, children: '查看 · 编辑写回(merge)· 按楼回看' })
|
|
684
|
+
]
|
|
685
|
+
}),
|
|
686
|
+
open && jsx.jsxs('div', { style: { padding: '0 14px 12px', display: 'flex', flexDirection: 'column', gap: 8 }, children: [
|
|
687
|
+
// 工具行:搜索 / 刷新(丢弃本地改动)/ 保存(merge 写回)/ 导出 JSON
|
|
688
|
+
jsx.jsxs('div', { style: { display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }, children: [
|
|
689
|
+
jsx.jsx('input', {
|
|
690
|
+
'data-dsh-muv-var-search': '', type: 'text', placeholder: '🔍 搜索变量路径或值…',
|
|
691
|
+
value: query, onChange: e => setQuery(e.target.value),
|
|
692
|
+
style: { flex: 1, minWidth: 140, padding: '3px 8px', fontSize: 12, background: C.bg3, color: C.text, border: '1px solid ' + C.border, borderRadius: 4, fontFamily: 'inherit', outline: 'none' }
|
|
693
|
+
}),
|
|
694
|
+
jsx.jsx('button', { 'data-dsh-muv-var-refresh': '', type: 'button', onClick: doRefresh, title: '重新从服务端读取(丢弃未保存的本地修改)', style: btnSm, children: '🔄 刷新' }),
|
|
695
|
+
jsx.jsx('button', { 'data-dsh-muv-var-save': '', type: 'button', onClick: doSave, title: '把已编辑的叶子写回服务端(merge)', style: btnSm, children: '💾 保存修改' + (Object.keys(edits).length ? '(' + Object.keys(edits).length + ')' : '') }),
|
|
696
|
+
jsx.jsx('button', { 'data-dsh-muv-var-export': '', type: 'button', onClick: doExport, title: '把当前变量树复制为 JSON', style: btnSm, children: '📋 导出 JSON' })
|
|
697
|
+
] }),
|
|
698
|
+
// ⏱ 按楼回看:探测失败 ⇒ snapList 为 null,整行不渲染(不报错不占位)
|
|
699
|
+
snapList && snapList.length > 0 && jsx.jsxs('div', { 'data-dsh-muv-var-snap-row': '', style: { display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }, children: [
|
|
700
|
+
jsx.jsx('span', { style: { fontSize: 12, color: C.gold }, children: '⏱ 按楼回看' }),
|
|
701
|
+
jsx.jsx('select', {
|
|
702
|
+
'data-dsh-muv-var-snap-select': '',
|
|
703
|
+
value: '',
|
|
704
|
+
onChange: e => enterSnap(e.target.value),
|
|
705
|
+
style: { flex: 1, minWidth: 160, padding: '2px 6px', fontSize: 12, background: C.bg3, color: C.text, border: '1px solid ' + C.border, borderRadius: 4 },
|
|
706
|
+
children: [jsx.jsx('option', { value: '', children: '— 选择楼层回看 —' }, '_none')].concat(
|
|
707
|
+
snapList.filter(s => s && s.key).map(s => jsx.jsx('option', { value: s.key, disabled: s.data == null, children: varSectionSnapLabel(s) }, s.key))
|
|
708
|
+
)
|
|
709
|
+
}),
|
|
710
|
+
snapMode && jsx.jsx('button', { 'data-dsh-muv-var-back-latest': '', type: 'button', onClick: exitSnap, title: '退出回看,恢复显示最新变量树', style: btnSm, children: '回到最新' })
|
|
711
|
+
] }),
|
|
712
|
+
jsx.jsx('div', {
|
|
713
|
+
'data-dsh-muv-var-status': '',
|
|
714
|
+
style: { fontSize: 11, lineHeight: 1.6, color: status ? (TC[status.tone] || C.dim) : C.dim },
|
|
715
|
+
children: status ? status.text : '(展开时自动读取当前会话的运行时变量)'
|
|
716
|
+
}),
|
|
717
|
+
// 树:嵌在外层滚动容器里,这里不自带任何滚动样式(防双滚动)
|
|
718
|
+
jsx.jsx('div', {
|
|
719
|
+
'data-dsh-muv-var-tree': '',
|
|
720
|
+
style: { fontSize: 12, lineHeight: 1.9, fontFamily: 'ui-monospace,Consolas,monospace' },
|
|
721
|
+
children: treeBody
|
|
722
|
+
})
|
|
723
|
+
] })
|
|
724
|
+
]
|
|
725
|
+
})
|
|
726
|
+
}
|
|
727
|
+
|
|
36
728
|
// ============ MacroPanel ============
|
|
37
729
|
function MacroPanel({ onClose }) {
|
|
38
730
|
const [input, setInput] = React.useState('')
|
|
@@ -151,23 +843,11 @@ window.__ModuleLoader__.load({
|
|
|
151
843
|
: { status:'idle', cardName:'', schemas:[], initvarData:{}, edits:{}, generatedBlock:'', error:'', search:'' }
|
|
152
844
|
const [state, setState] = React.useState(initial)
|
|
153
845
|
const [collapsed, setCollapsed] = React.useState(new Set())
|
|
846
|
+
const [varTick, setVarTick] = React.useState(0) // 📊 运行时变量区的重读信号(见 check() 内)
|
|
154
847
|
const fileRef = React.useRef(null)
|
|
155
848
|
const hasAutoLoaded = React.useRef(false)
|
|
156
849
|
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
// Which preset is active has exactly one source of truth: the tavern
|
|
160
|
-
// plugin's own selection, which it publishes on these DOM nodes (and
|
|
161
|
-
// mirrors to localStorage). `lastPresetId` was previously only set by a
|
|
162
|
-
// `tavern-preset-changed` event, which does not fire on page load — so the
|
|
163
|
-
// first request carried no preset and the server fell back to a hardcoded
|
|
164
|
-
// one, showing an unrelated card.
|
|
165
|
-
//
|
|
166
|
-
// Reading the DOM here rather than caching an event value matters: the
|
|
167
|
-
// panel polls, and the user can switch presets at any time. An earlier
|
|
168
|
-
// revision cached the first answer and never re-checked, which pinned the
|
|
169
|
-
// panel to whatever preset happened to be active when it opened.
|
|
170
|
-
// 当前 DSH 会话 id —— 这才是「我在哪个会话」的权威依据。
|
|
850
|
+
// 「我在哪个会话 / 该显示哪张卡」的权威依据 = **当前 DSH 会话 id**。
|
|
171
851
|
//
|
|
172
852
|
// 起因:用户报告「不管点哪个会话,面板永远显示同一张卡」。查下来服务端没问题
|
|
173
853
|
// (按 presetId 请求,每个预设都返回各自的卡),是客户端一直在发同一个预设。
|
|
@@ -175,7 +855,16 @@ window.__ModuleLoader__.load({
|
|
|
175
855
|
// 在切换会话时不一定跟着更新(酒馆自己在 bundle.js:1655 就是
|
|
176
856
|
// `savedPid || lbl.dataset.presetId` —— savedPid 为空时会**保留旧值**),
|
|
177
857
|
// 而 localStorage 那份更是切换预设时才写。
|
|
178
|
-
// 会话 id
|
|
858
|
+
// 会话 id 则不会撒谎:服务端按它查出该会话绑的预设。
|
|
859
|
+
//
|
|
860
|
+
// ★ 注意(2026-09 修):这里原先把「酒馆 DOM / localStorage 上的当前预设」当成
|
|
861
|
+
// "唯一真实来源",并在认不出会话时拿它当 `presetId` 发给服务端。那是个陷阱:
|
|
862
|
+
// ① 它本身会「粘住」(切会话后仍是上一个会话的值);
|
|
863
|
+
// ② 服务端会对 presetId 调 `fromExplicit()`,于是这个"猜"被标成
|
|
864
|
+
// `presetSource='explicit'` —— 听起来像"用户明确指定",其实是猜,
|
|
865
|
+
// 串台向量只是从服务端搬到了客户端,标签还更自信了。
|
|
866
|
+
// 现在:DOM/localStorage 这条读取路径**已整条删除**(`readActivePresetFromDom`
|
|
867
|
+
// 一并移除),认不出会话就什么都不带,由服务端落到稳定默认并如实上报来源。
|
|
179
868
|
const currentSessionId = () => {
|
|
180
869
|
try {
|
|
181
870
|
const svc = window.__DSH_TAVERN_SESSIONS__
|
|
@@ -198,47 +887,42 @@ window.__ModuleLoader__.load({
|
|
|
198
887
|
return ''
|
|
199
888
|
}
|
|
200
889
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const aid = item && item.getAttribute && item.getAttribute('data-preset-id')
|
|
210
|
-
if (aid) return aid
|
|
211
|
-
} catch (_) {}
|
|
212
|
-
try { return localStorage.getItem('dsh-tavern-active-preset') || '' } catch (_) { return '' }
|
|
213
|
-
}
|
|
890
|
+
// ★ 已删除 `readActivePresetFromDom()`(原来读 `#tavern-session-preset-label`
|
|
891
|
+
// / `#tavern-session-preset-btn` 的 dataset.presetId,再退到
|
|
892
|
+
// localStorage 的 `dsh-tavern-active-preset`)。
|
|
893
|
+
// 删除原因见上:它提供的是**会粘住的猜**,而它唯一的消费者(把猜出来的值当
|
|
894
|
+
// `presetId` 发给服务端)已经移除,留着就是一段"看起来还在生效"的死代码。
|
|
895
|
+
// 如果将来真的需要「用户此刻在酒馆面板里选了哪个预设」(例如给 UI 显示用),
|
|
896
|
+
// 请从**服务端权威解析**取值(酒馆的 `GET /api/tavern/current-session`),
|
|
897
|
+
// 不要再回到 DOM/localStorage。
|
|
214
898
|
|
|
215
|
-
//
|
|
899
|
+
// 本次请求要带的定位参数。**会话 id 是唯一可用的依据** —— 它随会话切换而变,
|
|
900
|
+
// 是唯一不会「粘住」的东西。
|
|
901
|
+
//
|
|
902
|
+
// ★ 这里原来有三级兜底(localStorage → 酒馆 DOM → 服务端 `/api/muv-table/active-preset`
|
|
903
|
+
// 按「最近写入的会话」猜),并把猜出来的值当 `presetId` 发出去。**这条路已整条删除**:
|
|
904
|
+
// 服务端在 `!preferPreset` 分支里会对 presetId 调 `fromExplicit()`,于是同一个"猜"
|
|
905
|
+
// 只是从服务端的 `presetSource='active'` 换成客户端的 `'explicit'` —— 标签更自信、
|
|
906
|
+
// 串台向量却分毫未减(`activePresetId()` 按会话文件 mtime 取"最近写入的会话",
|
|
907
|
+
// 开发/被测会话在不停写文件时这个值会漂移:实测它当时返回的是「安装 dsh-tavern-v2
|
|
908
|
+
// 及其附属插件」那个**开发会话**的绑定)。**猜的链条不能只堵一半。**
|
|
909
|
+
//
|
|
910
|
+
// 现在的语义(与酒馆侧 `client.manager.bundle.js` 的状态栏取卡对齐):
|
|
911
|
+
// 认不出会话 ⇒ **什么都不带** ⇒ 服务端落到稳定默认(tavern-lite),
|
|
912
|
+
// 并在 `presetSource` 里如实标 `default`。
|
|
913
|
+
// 确实想看某个预设的卡(卡库检查、诊断脚本)请显式加 `preferPreset=1`,语义不混。
|
|
216
914
|
let lastSessionId = ''
|
|
217
915
|
const resolveActivePreset = async () => {
|
|
218
916
|
lastSessionId = currentSessionId()
|
|
219
|
-
// 有会话 id 就不必猜:服务端会按 session-bindings.json 查出该会话绑的预设。
|
|
220
|
-
if (lastSessionId) return lastPresetId
|
|
221
|
-
// 拿不到会话 id 时才退回酒馆 DOM;DOM 元素在酒馆 UI 之外不存在。
|
|
222
|
-
const fromDom = readActivePresetFromDom()
|
|
223
|
-
if (fromDom) { lastPresetId = fromDom; return lastPresetId }
|
|
224
|
-
// 最后才让服务端按「最近写入的会话」猜。
|
|
225
|
-
try {
|
|
226
|
-
const r = await fetch('/api/muv-table/active-preset')
|
|
227
|
-
const d = await r.json()
|
|
228
|
-
if (d && d.ok && d.presetId) lastPresetId = d.presetId
|
|
229
|
-
} catch (_) {}
|
|
230
|
-
return lastPresetId
|
|
231
917
|
}
|
|
232
918
|
|
|
233
919
|
const fetchTavernCard = async () => {
|
|
234
920
|
await resolveActivePreset()
|
|
235
921
|
let url = '/api/muv-table/tavern-card'
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
// 切换会话必然跟着变,这正是「不管点哪个会话都显示同一张卡」的解药。
|
|
922
|
+
// 只带会话 id:两个参数都传时服务端 presetId 会盖掉会话,而 presetId 恰恰是
|
|
923
|
+
// 可能「粘住」的那个 —— 会话 id 随会话切换必然变,这正是「不管点哪个会话都显示
|
|
924
|
+
// 同一张卡」的解药。
|
|
240
925
|
if (lastSessionId) url += '?sessionId=' + encodeURIComponent(lastSessionId)
|
|
241
|
-
else if (lastPresetId) url += '?presetId=' + encodeURIComponent(lastPresetId)
|
|
242
926
|
const r = await fetch(url)
|
|
243
927
|
return r.json()
|
|
244
928
|
}
|
|
@@ -265,10 +949,14 @@ window.__ModuleLoader__.load({
|
|
|
265
949
|
const syncKey = d.name + '|' + (d.presetDir || '')
|
|
266
950
|
if (syncKey !== lastSyncKey.current || forceReload) {
|
|
267
951
|
forceReload = false
|
|
952
|
+
// 📊 运行时变量区挂在**同一批**监听上:tavern-preset-changed /
|
|
953
|
+
// tavern-session-changed → forceReload → 这里消费并 bump varTick,
|
|
954
|
+
// 新区(RuntimeVarsSection)凭 reloadToken 变化重读,不另挂第二套监听。
|
|
955
|
+
setVarTick(t => t + 1)
|
|
268
956
|
lastSyncKey.current = syncKey
|
|
269
957
|
savedState = { cardName:d.name, schemas:d.schemas, initvarData:d.initvarData, edits:{} }; saveSS(savedState)
|
|
270
958
|
setCollapsed(new Set())
|
|
271
|
-
setState({status:'loaded',cardName:d.name,schemas:d.schemas,initvarData:d.initvarData,edits:{},generatedBlock:'',error:'',search:''})
|
|
959
|
+
setState({status:'loaded',cardName:d.name,schemas:d.schemas,initvarData:d.initvarData,edits:{},generatedBlock:'',error:'',search:'',presetSource:d.presetSource||'',presetDir:d.presetDir||''})
|
|
272
960
|
}
|
|
273
961
|
}
|
|
274
962
|
} catch (_) {}
|
|
@@ -298,7 +986,7 @@ window.__ModuleLoader__.load({
|
|
|
298
986
|
if(!d.ok) throw new Error(d.error)
|
|
299
987
|
const schemas = d.schemas || []
|
|
300
988
|
savedState = { cardName:d.name, schemas, initvarData:d.initvarData||{}, edits:{} }; saveSS(savedState)
|
|
301
|
-
setCollapsed(new Set()); setState({status:'loaded',cardName:d.name,schemas,initvarData:d.initvarData||{},edits:{},generatedBlock:'',error:'',search:''})
|
|
989
|
+
setCollapsed(new Set()); setState({status:'loaded',cardName:d.name,schemas,initvarData:d.initvarData||{},edits:{},generatedBlock:'',error:'',search:'',presetSource:d.presetSource||'',presetDir:d.presetDir||''})
|
|
302
990
|
} catch(e) { setState(s=>({...s,status:'error',error:e.message})) }
|
|
303
991
|
}
|
|
304
992
|
const handleEdit = (path, val) => setState(s=>{ const e={...s.edits,[path]:val}; savedState={...savedState,edits:e}; saveSS(savedState); return {...s,edits:e} })
|
|
@@ -370,6 +1058,23 @@ window.__ModuleLoader__.load({
|
|
|
370
1058
|
// Header
|
|
371
1059
|
jsx.jsxs('div', { style:{display:'flex',alignItems:'center',gap:10,padding:'10px 16px',borderBottom:'1px solid '+C.border,flexShrink:0,background:C.bg1}, children: [
|
|
372
1060
|
jsx.jsx('span', { style:{fontWeight:700,fontSize:15,flex:1}, children: state.cardName?'📊 '+state.cardName:'📊 MUV 变量表格' }),
|
|
1061
|
+
// ★ 卡片来源指示(presetSource)——服务端一直在如实上报,但**从来没人消费它**,
|
|
1062
|
+
// 于是用户在未绑定会话里只看到"一张不知从哪来的卡",看不出问题在哪。
|
|
1063
|
+
// 这里把它显式化:`session-default` / `default` = 该会话**没有绑定角色卡**,
|
|
1064
|
+
// 面板当前用的是酒馆默认预设(tavern-lite)⇒ 必须让用户看见,而不是静默。
|
|
1065
|
+
// 其余取值(explicit/session/active)只做中性提示,方便排障。
|
|
1066
|
+
state.status==='loaded' && state.presetSource && (function () {
|
|
1067
|
+
var unbound = state.presetSource === 'default' || state.presetSource === 'session-default'
|
|
1068
|
+
return jsx.jsx('span', {
|
|
1069
|
+
title: 'presetSource=' + state.presetSource + '\n预设目录: ' + (state.presetDir || '(未知)') +
|
|
1070
|
+
(unbound ? '\n\n本会话没有绑定角色卡,当前显示的是酒馆默认预设。\n要换卡:在酒馆面板选好预设后**新开一个对话**(已开始的会话角色卡已锁定)。' : ''),
|
|
1071
|
+
style:{fontSize:11,padding:'2px 8px',borderRadius:10,whiteSpace:'nowrap',cursor:'help',
|
|
1072
|
+
background: unbound ? 'rgba(231,76,60,0.15)' : 'rgba(74,191,171,0.12)',
|
|
1073
|
+
color: unbound ? 'var(--dsw-alias-state-error-primary,#e74c3c)' : C.accent,
|
|
1074
|
+
border: '1px solid ' + (unbound ? 'rgba(231,76,60,0.35)' : 'rgba(74,191,171,0.3)')},
|
|
1075
|
+
children: unbound ? '⚠️ 本会话未绑卡(默认预设)' : '来源: ' + state.presetSource
|
|
1076
|
+
})
|
|
1077
|
+
})(),
|
|
373
1078
|
state.status==='loaded' && jsx.jsx('input', { type:'text', placeholder:'🔍 搜索变量...', value:state.search, onChange:e=>setState(s=>({...s,search:e.target.value})), style:{width:160,padding:'4px 10px',borderRadius:6,border:'1px solid '+C.border,background:C.bg3,color:C.text,fontSize:12,fontFamily:'inherit',outline:'none'} }),
|
|
374
1079
|
editCount>0 && jsx.jsx('span', { style:{fontSize:11,color:C.sub,background:'rgba(197,160,101,0.12)',padding:'2px 8px',borderRadius:10}, children: editCount+' 修改' }),
|
|
375
1080
|
state.status==='loaded' && jsx.jsx('button', { onClick:()=>showMacroPanel(), style:{padding:'5px 10px',borderRadius:6,border:'1px solid '+C.accent,background:'rgba(74,191,171,0.1)',color:C.accent,cursor:'pointer',fontSize:11,fontFamily:'inherit'}, children: '📐 宏' }),
|
|
@@ -379,6 +1084,14 @@ window.__ModuleLoader__.load({
|
|
|
379
1084
|
jsx.jsx('button', { onClick:onClose, style:{padding:'5px 12px',borderRadius:6,border:'1px solid '+C.border,background:C.bg3,color:C.text,cursor:'pointer',fontSize:12,fontFamily:'inherit'}, children: '✕' })
|
|
380
1085
|
] }),
|
|
381
1086
|
|
|
1087
|
+
// 🧰 卡运维(卡相关入口,按用户要求挂在 **muv-table 自己的面板** 里,不放酒馆面板)
|
|
1088
|
+
// 只读 + 一个动作,全部失败都降级成文字提示,不影响下方的变量树。
|
|
1089
|
+
state.status==='loaded' && jsx.jsx(CardOpsPanel, {
|
|
1090
|
+
sessionId: currentSessionId(),
|
|
1091
|
+
cardName: state.cardName || '',
|
|
1092
|
+
presetDir: state.presetDir || ''
|
|
1093
|
+
}),
|
|
1094
|
+
|
|
382
1095
|
// Content
|
|
383
1096
|
state.status==='idle' && jsx.jsxs('div', { style:{flex:1,display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',gap:16,padding:24}, children: [
|
|
384
1097
|
savedState && savedState.cardName && jsx.jsxs('div', { style:{background:C.bg2,border:'1px solid '+C.border,borderRadius:10,padding:'14px 24px',textAlign:'center',maxWidth:400,width:'100%'}, children: [
|
|
@@ -397,14 +1110,24 @@ window.__ModuleLoader__.load({
|
|
|
397
1110
|
state.error && jsx.jsx('div', { style:{padding:12,color:C.danger}, children: '⚠ '+state.error }),
|
|
398
1111
|
|
|
399
1112
|
// Tree
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
1113
|
+
// Content(★ 全面板**唯一**的滚动容器:flex:1 + 自动滚动。📊 运行时变量区和
|
|
1114
|
+
// 变量树都嵌在这层里滚,新区自己不自带任何滚动样式 —— 防双滚动,
|
|
1115
|
+
// 旧面板 max-height 内滚的双滚动条问题就此消灭)
|
|
1116
|
+
state.status==='loaded' && jsx.jsxs('div', { style:{flex:1,overflow:'auto',padding:'8px 12px'}, children: [
|
|
1117
|
+
// 📊 运行时变量(自 tavern-v2 面板迁入):sessionId 复用 currentSessionId(),
|
|
1118
|
+
// initvarData 直接用 state 里 tavern-card 加载时带回来的那份,不二次请求。
|
|
1119
|
+
jsx.jsx(RuntimeVarsSection, {
|
|
1120
|
+
sessionId: currentSessionId(),
|
|
1121
|
+
initvarData: state.initvarData,
|
|
1122
|
+
reloadToken: varTick
|
|
1123
|
+
}),
|
|
1124
|
+
state.schemas.length===0 && jsx.jsxs('div', { style:{display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',gap:12,padding:'24px 8px',color:C.dim}, children: [
|
|
1125
|
+
jsx.jsx('div', { style:{fontSize:14,fontWeight:600,color:C.text}, children: '📋 '+state.cardName }),
|
|
1126
|
+
jsx.jsx('div', { style:{fontSize:12}, children: '此卡没有预设初始变量' }),
|
|
1127
|
+
jsx.jsx('div', { style:{fontSize:11,textAlign:'center',lineHeight:1.6}, children: '玩起来后 LLM 输出 <UpdateVariable> 块时,变量会自动出现在这里。' }),
|
|
1128
|
+
jsx.jsx('div', { style:{fontSize:11,color:C.gold}, children: '也可以手动拖放角色卡 JSON 文件加载初始数据。' })
|
|
1129
|
+
] }),
|
|
1130
|
+
state.schemas.length>0 && state.schemas.filter(f=>!state.search||f.name.includes(state.search)||JSON.stringify(f).includes(state.search)).map(field =>
|
|
408
1131
|
jsx.jsxs('div', { key:field.name, style:{marginBottom:10,background:C.bg1,border:'1px solid '+C.border,borderRadius:8,overflow:'hidden'}, children: [
|
|
409
1132
|
jsx.jsxs('div', { onClick:()=>toggleCollapse(field.name), style:{display:'flex',alignItems:'center',gap:8,padding:'8px 14px',cursor:'pointer',userSelect:'none',background:collapsed.has(field.name)?C.bg1:C.bg2,borderBottom:collapsed.has(field.name)?'none':'1px solid '+C.border}, onMouseEnter:e=>e.currentTarget.style.background=C.hover, onMouseLeave:e=>e.currentTarget.style.background=collapsed.has(field.name)?C.bg1:C.bg2, children: [
|
|
410
1133
|
jsx.jsx('span', { style:{fontSize:12,color:C.dim,width:16}, children: collapsed.has(field.name)?'▸':'▾' }),
|
|
@@ -416,7 +1139,7 @@ window.__ModuleLoader__.load({
|
|
|
416
1139
|
})
|
|
417
1140
|
] })
|
|
418
1141
|
)
|
|
419
|
-
}),
|
|
1142
|
+
] }),
|
|
420
1143
|
|
|
421
1144
|
// Generated block
|
|
422
1145
|
state.generatedBlock && jsx.jsxs('div', { style:{flexShrink:0,borderTop:'2px solid '+C.accent}, children: [
|
|
@@ -454,7 +1177,6 @@ window.__ModuleLoader__.load({
|
|
|
454
1177
|
|
|
455
1178
|
// ============ apply ============
|
|
456
1179
|
let forceReload = false
|
|
457
|
-
let lastPresetId = ''
|
|
458
1180
|
|
|
459
1181
|
// ── DOM observer: detect session / preset switches by watching the URL ──
|
|
460
1182
|
let _lastPath = window.location.pathname + window.location.search
|
|
@@ -474,9 +1196,11 @@ window.__ModuleLoader__.load({
|
|
|
474
1196
|
exports.inject = []
|
|
475
1197
|
exports.apply = function () {
|
|
476
1198
|
// Listen for tavern preset changes (dispatched by other plugins or DSH internals)
|
|
477
|
-
|
|
1199
|
+
// ★ 只用来触发重载 —— 事件里的 `presetId` **不再**被记住、更不会当"当前预设"
|
|
1200
|
+
// 发出去。切会话时这个事件可能带着上一个会话的预设(粘住),而定位依据已统一
|
|
1201
|
+
// 为会话 id(见上面 currentSessionId 的说明)。
|
|
1202
|
+
const onPresetChanged = () => {
|
|
478
1203
|
forceReload = true
|
|
479
|
-
lastPresetId = e.detail?.presetId || ''
|
|
480
1204
|
}
|
|
481
1205
|
document.addEventListener('tavern-preset-changed', onPresetChanged)
|
|
482
1206
|
|