Files
LittleWhiteBox/modules/variables/variables-panel.js
2026-01-17 16:34:39 +08:00

681 lines
37 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { extension_settings, getContext, saveMetadataDebounced } from "../../../../../extensions.js";
import { saveSettingsDebounced, chat_metadata } from "../../../../../../script.js";
import { getLocalVariable, setLocalVariable, getGlobalVariable, setGlobalVariable } from "../../../../../variables.js";
import { extensionFolderPath } from "../../core/constants.js";
import { createModuleEvents, event_types } from "../../core/event-manager.js";
const CONFIG = {
extensionName: "variables-panel",
extensionFolderPath,
defaultSettings: { enabled: false },
watchInterval: 1500, touchTimeout: 4000, longPressDelay: 700,
};
const EMBEDDED_CSS = `
.vm-container{color:var(--SmartThemeBodyColor);background:var(--SmartThemeBlurTintColor);flex-direction:column;overflow-y:auto;z-index:3000;position:fixed;display:none}
.vm-container:not([style*="display: none"]){display:flex}
@media (min-width: 1000px){.vm-container:not([style*="display: none"]){width:calc((100vw - var(--sheldWidth)) / 2);border-left:1px solid var(--SmartThemeBorderColor);right:0;top:0;height:100vh}}
@media (max-width: 999px){.vm-container:not([style*="display: none"]){max-height:calc(100svh - var(--topBarBlockSize));top:var(--topBarBlockSize);width:100%;height:100vh;left:0}}
.vm-header,.vm-section,.vm-item-content{border-bottom:.5px solid var(--SmartThemeBorderColor)}
.vm-header,.vm-section-header{display:flex;justify-content:space-between;align-items:center}
.vm-title,.vm-item-name{font-weight:bold}
.vm-header{padding:15px}.vm-title{font-size:16px}
.vm-section-header{padding:5px 15px;border-bottom:5px solid var(--SmartThemeBorderColor);font-size:14px;color:var(--SmartThemeEmColor)}
.vm-close,.vm-btn{background:none;border:none;cursor:pointer;display:inline-flex;align-items:center;justify-content:center}
.vm-close{font-size:18px;padding:5px}
.vm-btn{border:1px solid var(--SmartThemeBorderColor);border-radius:3px;font-size:12px;padding:2px 4px;color:var(--SmartThemeBodyColor)}
.vm-search-container{padding:10px;border-bottom:1px solid var(--SmartThemeBorderColor)}
.vm-search-input{width:100%;padding:3px 6px}
.vm-clear-all-btn{color:#ff6b6b;border-color:#ff6b6b;opacity:.3}
.vm-list{flex:1;overflow-y:auto;padding:10px}
.vm-item{border:1px solid var(--SmartThemeBorderColor);opacity:.7}
.vm-item.expanded{opacity:1}
.vm-item-header{display:flex;justify-content:space-between;align-items:center;cursor:pointer;padding-left:5px}
.vm-item-name{font-size:13px}
.vm-item-controls{background:var(--SmartThemeChatTintColor);display:flex;gap:5px;position:absolute;right:5px;opacity:0;visibility:hidden}
.vm-item-content{border-top:1px solid var(--SmartThemeBorderColor);display:none}
.vm-item.expanded>.vm-item-content{display:block}
.vm-inline-form{background:var(--SmartThemeChatTintColor);border:1px solid var(--SmartThemeBorderColor);border-top:none;padding:10px;margin:0;display:none}
.vm-inline-form.active{display:block;animation:slideDown .2s ease-out}
@keyframes slideDown{from{opacity:0;max-height:0;padding-top:0;padding-bottom:0}to{opacity:1;max-height:200px;padding-top:10px;padding-bottom:10px}}
@media (hover:hover){.vm-close:hover,.vm-btn:hover{opacity:.8}.vm-close:hover{color:red}.vm-clear-all-btn:hover{opacity:1}.vm-item:hover>.vm-item-header .vm-item-controls{opacity:1;visibility:visible}.vm-list:hover::-webkit-scrollbar-thumb{background:var(--SmartThemeQuoteColor)}.vm-variable-checkbox:hover{background-color:rgba(255,255,255,.1)}}
@media (hover:none){.vm-close:active,.vm-btn:active{opacity:.8}.vm-close:active{color:red}.vm-clear-all-btn:active{opacity:1}.vm-item:active>.vm-item-header .vm-item-controls,.vm-item.touched>.vm-item-header .vm-item-controls{opacity:1;visibility:visible}.vm-item.touched>.vm-item-header{background-color:rgba(255,255,255,.05)}.vm-btn:active{background-color:rgba(255,255,255,.1);transform:scale(.95)}.vm-variable-checkbox:active{background-color:rgba(255,255,255,.1)}}
.vm-item:not([data-level]).expanded .vm-item[data-level="1"]{--level-color:hsl(36,100%,50%)}
.vm-item[data-level="1"].expanded .vm-item[data-level="2"]{--level-color:hsl(60,100%,50%)}
.vm-item[data-level="2"].expanded .vm-item[data-level="3"]{--level-color:hsl(120,100%,50%)}
.vm-item[data-level="3"].expanded .vm-item[data-level="4"]{--level-color:hsl(180,100%,50%)}
.vm-item[data-level="4"].expanded .vm-item[data-level="5"]{--level-color:hsl(240,100%,50%)}
.vm-item[data-level="5"].expanded .vm-item[data-level="6"]{--level-color:hsl(280,100%,50%)}
.vm-item[data-level="6"].expanded .vm-item[data-level="7"]{--level-color:hsl(320,100%,50%)}
.vm-item[data-level="7"].expanded .vm-item[data-level="8"]{--level-color:hsl(200,100%,50%)}
.vm-item[data-level="8"].expanded .vm-item[data-level="9"]{--level-color:hsl(160,100%,50%)}
.vm-item[data-level]{border-left:2px solid var(--level-color);margin-left:6px}
.vm-item[data-level]:last-child{border-bottom:2px solid var(--level-color)}
.vm-tree-value,.vm-variable-checkbox span{font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.vm-tree-value{color:inherit;font-size:12px;flex:1;margin:0 10px}
.vm-input,.vm-textarea{border:1px solid var(--SmartThemeBorderColor);border-radius:3px;background-color:var(--SmartThemeChatTintColor);font-size:12px;margin:3px 0}
.vm-textarea{min-height:60px;padding:5px;font-family:monospace;resize:vertical}
.vm-add-form{padding:10px;border-top:1px solid var(--SmartThemeBorderColor);display:none}
.vm-add-form.active{display:block}
.vm-form-row{display:flex;gap:10px;margin-bottom:10px;align-items:center}
.vm-form-label{min-width:30px;font-size:12px;font-weight:bold}
.vm-form-input{flex:1}
.vm-form-buttons{display:flex;gap:5px;justify-content:flex-end}
.vm-list::-webkit-scrollbar{width:6px}
.vm-list::-webkit-scrollbar-track{background:var(--SmartThemeBodyColor)}
.vm-list::-webkit-scrollbar-thumb{background:var(--SmartThemeBorderColor);border-radius:3px}
.vm-empty-message{padding:20px;text-align:center;color:#888}
.vm-item-name-visible{opacity:1}
.vm-item-separator{opacity:.3}
.vm-null-value{opacity:.6}
.mes_btn.mes_variables_panel{opacity:.6}
.mes_btn.mes_variables_panel:hover{opacity:1}
.vm-badges{display:inline-flex;gap:6px;margin-left:6px;align-items:center}
.vm-badge[data-type="ro"]{color:#F9C770}
.vm-badge[data-type="struct"]{color:#48B0C7}
.vm-badge[data-type="cons"]{color:#D95E37}
.vm-badge:hover{opacity:1;filter:saturate(1.2)}
:root{--vm-badge-nudge:0.06em}
.vm-item-name{display:inline-flex;align-items:center}
.vm-badges{display:inline-flex;gap:.35em;margin-left:.35em}
.vm-item-name .vm-badge{display:flex;width:1em;position:relative;top:var(--vm-badge-nudge) !important;opacity:.9}
.vm-item-name .vm-badge i{display:block;font-size:.8em;line-height:1em}
`;
const EMBEDDED_HTML = `
<div id="vm-container" class="vm-container" style="display:none">
<div class="vm-header">
<div class="vm-title">变量面板</div>
<button id="vm-close" class="vm-close"><i class="fa-solid fa-times"></i></button>
</div>
<div class="vm-content">
${['character','global'].map(t=>`
<div class="vm-section" id="${t}-variables-section">
<div class="vm-section-header">
<div class="vm-section-title"><i class="fa-solid ${t==='character'?'fa-user':'fa-globe'}"></i>${t==='character'?' 本地变量':' 全局变量'}</div>
<div class="vm-section-controls">
${[['import','fa-upload','导入变量'],['export','fa-download','导出变量'],['add','fa-plus','添加变量'],['collapse','fa-chevron-down','展开/折叠所有'],['clear-all','fa-trash','清除所有变量']].map(([a,ic,ti])=>`<button class="vm-btn ${a==='clear-all'?'vm-clear-all-btn':''}" data-type="${t}" data-act="${a}" title="${ti}"><i class="fa-solid ${ic}"></i></button>`).join('')}
</div>
</div>
<div class="vm-search-container"><input type="text" class="vm-input vm-search-input" id="${t}-vm-search" placeholder="搜索${t==='character'?'本地':'全局'}变量..."></div>
<div class="vm-list" id="${t}-variables-list"></div>
<div class="vm-add-form" id="${t}-vm-add-form">
<div class="vm-form-row"><label class="vm-form-label">名称:</label><input type="text" class="vm-input vm-form-input" id="${t}-vm-name" placeholder="变量名称"></div>
<div class="vm-form-row"><label class="vm-form-label">值:</label><textarea class="vm-textarea vm-form-input" id="${t}-vm-value" placeholder="变量值 (支持JSON格式)"></textarea></div>
<div class="vm-form-buttons">
<button class="vm-btn" data-type="${t}" data-act="save-add"><i class="fa-solid fa-floppy-disk"></i>保存</button>
<button class="vm-btn" data-type="${t}" data-act="cancel-add">取消</button>
</div>
</div>
</div>`).join('')}
</div>
</div>
`;
const VT = {
character: { getter: getLocalVariable, setter: setLocalVariable, storage: ()=> chat_metadata?.variables || (chat_metadata.variables = {}), save: saveMetadataDebounced },
global: { getter: getGlobalVariable, setter: setGlobalVariable, storage: ()=> extension_settings.variables?.global || ((extension_settings.variables = { global: {} }).global), save: saveSettingsDebounced },
};
const LWB_RULES_KEY='LWB_RULES';
const getRulesTable = () => { try { return getContext()?.chatMetadata?.[LWB_RULES_KEY] || {}; } catch { return {}; } };
const pathKey = (arr)=>{ try { return (arr||[]).map(String).join('.'); } catch { return ''; } };
const getRuleNodeByPath = (arr)=> (pathKey(arr) ? (getRulesTable()||{})[pathKey(arr)] : undefined);
const hasAnyRule = (n)=>{
if(!n) return false;
if(n.ro) return true;
if(n.objectPolicy && n.objectPolicy!=='none') return true;
if(n.arrayPolicy && n.arrayPolicy!=='lock') return true;
const c=n.constraints||{};
return ('min'in c)||('max'in c)||('step'in c)||(Array.isArray(c.enum)&&c.enum.length)||(c.regex&&c.regex.source);
};
const ruleTip = (n)=>{
if(!n) return '';
const lines=[], c=n.constraints||{};
if(n.ro) lines.push('只读:$ro');
if(n.objectPolicy){ const m={none:'(默认:不可增删键)',ext:'$ext可增键',prune:'$prune可删键',free:'$free可增删键'}; lines.push(`对象策略:${m[n.objectPolicy]||n.objectPolicy}`); }
if(n.arrayPolicy){ const m={lock:'(默认:不可增删项)',grow:'$grow可增项',shrink:'$shrink可删项',list:'$list可增删项'}; lines.push(`数组策略:${m[n.arrayPolicy]||n.arrayPolicy}`); }
if('min'in c||'max'in c){ if('min'in c&&'max'in c) lines.push(`范围:$range=[${c.min},${c.max}]`); else if('min'in c) lines.push(`下限:$min=${c.min}`); else lines.push(`上限:$max=${c.max}`); }
if('step'in c) lines.push(`步长:$step=${c.step}`);
if(Array.isArray(c.enum)&&c.enum.length) lines.push(`枚举:$enum={${c.enum.join(';')}}`);
if(c.regex&&c.regex.source) lines.push(`正则:$match=/${c.regex.source}/${c.regex.flags||''}`);
return lines.join('\n');
};
const badgesHtml = (n)=>{
if(!hasAnyRule(n)) return '';
const tip=ruleTip(n).replace(/"/g,'&quot;'), out=[];
if(n.ro) out.push(`<span class="vm-badge" data-type="ro" title="${tip}"><i class="fa-solid fa-shield-halved"></i></span>`);
if((n.objectPolicy&&n.objectPolicy!=='none')||(n.arrayPolicy&&n.arrayPolicy!=='lock')) out.push(`<span class="vm-badge" data-type="struct" title="${tip}"><i class="fa-solid fa-diagram-project"></i></span>`);
const c=n.constraints||{}; if(('min'in c)||('max'in c)||('step'in c)||(Array.isArray(c.enum)&&c.enum.length)||(c.regex&&c.regex.source)) out.push(`<span class="vm-badge" data-type="cons" title="${tip}"><i class="fa-solid fa-ruler-vertical"></i></span>`);
return out.length?`<span class="vm-badges">${out.join('')}</span>`:'';
};
const debounce=(fn,ms=200)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms);}};
class VariablesPanel {
constructor(){
this.state={isOpen:false,isEnabled:false,container:null,timers:{watcher:null,longPress:null,touch:new Map()},currentInlineForm:null,formState:{},rulesChecksum:''};
this.variableSnapshot=null; this.eventHandlers={}; this.savingInProgress=false; this.containerHtml=EMBEDDED_HTML;
}
async init(){
this.injectUI(); this.bindControlToggle();
const s=this.getSettings(); this.state.isEnabled=s.enabled; this.syncCheckbox();
if(s.enabled) this.enable();
}
injectUI(){
if(!document.getElementById('variables-panel-css')){
const st=document.createElement('style'); st.id='variables-panel-css'; st.textContent=EMBEDDED_CSS; document.head.appendChild(st);
}
}
getSettings(){ extension_settings.LittleWhiteBox ??= {}; return extension_settings.LittleWhiteBox.variablesPanel ??= {...CONFIG.defaultSettings}; }
vt(t){ return VT[t]; }
store(t){ return this.vt(t).storage(); }
enable(){
this.createContainer(); this.bindEvents();
['character','global'].forEach(t=>this.normalizeStore(t));
this.loadVariables(); this.installMessageButtons();
}
disable(){ this.cleanup(); }
cleanup(){
this.stopWatcher(); this.unbindEvents(); this.unbindControlToggle(); this.removeContainer(); this.removeAllMessageButtons();
const tm=this.state.timers; if(tm.watcher) clearInterval(tm.watcher); if(tm.longPress) clearTimeout(tm.longPress);
tm.touch.forEach(x=>clearTimeout(x)); tm.touch.clear();
Object.assign(this.state,{isOpen:false,timers:{watcher:null,longPress:null,touch:new Map()},currentInlineForm:null,formState:{},rulesChecksum:''});
this.variableSnapshot=null; this.savingInProgress=false;
}
createContainer(){
if(!this.state.container?.length){
$('body').append(this.containerHtml);
this.state.container=$("#vm-container");
$("#vm-close").off('click').on('click',()=>this.close());
}
}
removeContainer(){ this.state.container?.remove(); this.state.container=null; }
open(){
if(!this.state.isEnabled) return toastr.warning('请先启用变量面板');
this.createContainer(); this.bindEvents(); this.state.isOpen=true; this.state.container.show();
this.state.rulesChecksum = JSON.stringify(getRulesTable()||{});
this.loadVariables(); this.startWatcher();
}
close(){ this.state.isOpen=false; this.stopWatcher(); this.unbindEvents(); this.removeContainer(); }
bindControlToggle(){
const id='xiaobaix_variables_panel_enabled';
const bind=()=>{
const cb=document.getElementById(id); if(!cb) return false;
this.handleCheckboxChange && cb.removeEventListener('change',this.handleCheckboxChange);
this.handleCheckboxChange=e=> this.toggleEnabled(e.target instanceof HTMLInputElement ? !!e.target.checked : false);
cb.addEventListener('change',this.handleCheckboxChange); if(cb instanceof HTMLInputElement) cb.checked=this.state.isEnabled; return true;
};
if(!bind()) setTimeout(bind,100);
}
unbindControlToggle(){
const cb=document.getElementById('xiaobaix_variables_panel_enabled');
if(cb && this.handleCheckboxChange) cb.removeEventListener('change',this.handleCheckboxChange);
this.handleCheckboxChange=null;
}
syncCheckbox(){ const cb=document.getElementById('xiaobaix_variables_panel_enabled'); if(cb instanceof HTMLInputElement) cb.checked=this.state.isEnabled; }
bindEvents(){
if(!this.state.container?.length) return;
this.unbindEvents();
const ns='.vm';
$(document)
.on(`click${ns}`,'.vm-section [data-act]',e=>this.onHeaderAction(e))
.on(`touchstart${ns}`,'.vm-item>.vm-item-header',e=>this.handleTouch(e))
.on(`click${ns}`,'.vm-item>.vm-item-header',e=>this.handleItemClick(e))
.on(`click${ns}`,'.vm-item-controls [data-act]',e=>this.onItemAction(e))
.on(`click${ns}`,'.vm-inline-form [data-act]',e=>this.onInlineAction(e))
.on(`mousedown${ns} touchstart${ns}`,'[data-act="copy"]',e=>this.bindCopyPress(e));
['character','global'].forEach(t=> $(`#${t}-vm-search`).on('input',e=>{
if(e.currentTarget instanceof HTMLInputElement) this.searchVariables(t,e.currentTarget.value);
else this.searchVariables(t,'');
}));
}
unbindEvents(){ $(document).off('.vm'); ['character','global'].forEach(t=> $(`#${t}-vm-search`).off('input')); }
onHeaderAction(e){
e.preventDefault(); e.stopPropagation();
const b=$(e.currentTarget), act=b.data('act'), t=b.data('type');
({
import:()=>this.importVariables(t),
export:()=>this.exportVariables(t),
add:()=>this.showAddForm(t),
collapse:()=>this.collapseAll(t),
'clear-all':()=>this.clearAllVariables(t),
'save-add':()=>this.saveAddVariable(t),
'cancel-add':()=>this.hideAddForm(t),
}[act]||(()=>{}))();
}
onItemAction(e){
e.preventDefault(); e.stopPropagation();
const btn=$(e.currentTarget), act=btn.data('act'), item=btn.closest('.vm-item'),
t=this.getVariableType(item), path=this.getItemPath(item);
({
edit: ()=>this.editAction(item,'edit',t,path),
'add-child': ()=>this.editAction(item,'addChild',t,path),
delete: ()=>this.handleDelete(item,t,path),
copy: ()=>{}
}[act]||(()=>{}))();
}
onInlineAction(e){ e.preventDefault(); e.stopPropagation(); const act=$(e.currentTarget).data('act'); act==='inline-save'? this.handleInlineSave($(e.currentTarget).closest('.vm-inline-form')) : this.hideInlineForm(); }
bindCopyPress(e){
e.preventDefault(); e.stopPropagation();
const start=Date.now();
this.state.timers.longPress=setTimeout(()=>{ this.handleCopy(e,true); this.state.timers.longPress=null; },CONFIG.longPressDelay);
const release=(re)=>{
if(this.state.timers.longPress){
clearTimeout(this.state.timers.longPress); this.state.timers.longPress=null;
if(re.type!=='mouseleave' && (Date.now()-start)<CONFIG.longPressDelay) this.handleCopy(e,false);
}
$(document).off('mouseup.vm touchend.vm mouseleave.vm',release);
};
$(document).on('mouseup.vm touchend.vm mouseleave.vm',release);
}
stringifyVar(v){ return typeof v==='string'? v : JSON.stringify(v); }
makeSnapshotMap(t){ const s=this.store(t), m={}; for(const[k,v] of Object.entries(s)) m[k]=this.stringifyVar(v); return m; }
startWatcher(){ this.stopWatcher(); this.updateSnapshot(); this.state.timers.watcher=setInterval(()=> this.state.isOpen && this.checkChanges(), CONFIG.watchInterval); }
stopWatcher(){ if(this.state.timers.watcher){ clearInterval(this.state.timers.watcher); this.state.timers.watcher=null; } }
updateSnapshot(){ this.variableSnapshot={ character:this.makeSnapshotMap('character'), global:this.makeSnapshotMap('global') }; }
expandChangedKeys(changed){
['character','global'].forEach(t=>{
const set=changed[t]; if(!set?.size) return;
setTimeout(()=>{
const list=$(`#${t}-variables-list .vm-item[data-key]`);
set.forEach(k=> list.filter((_,el)=>$(el).data('key')===k).addClass('expanded'));
},10);
});
}
checkChanges(){
try{
const sum=JSON.stringify(getRulesTable()||{});
if(sum!==this.state.rulesChecksum){
this.state.rulesChecksum=sum;
const keep=this.saveAllExpandedStates();
this.loadVariables(); this.restoreAllExpandedStates(keep);
}
const cur={ character:this.makeSnapshotMap('character'), global:this.makeSnapshotMap('global') };
const changed={character:new Set(), global:new Set()};
['character','global'].forEach(t=>{
const prev=this.variableSnapshot?.[t]||{}, now=cur[t];
new Set([...Object.keys(prev),...Object.keys(now)]).forEach(k=>{ if(!(k in prev)||!(k in now)||prev[k]!==now[k]) changed[t].add(k);});
});
if(changed.character.size||changed.global.size){
const keep=this.saveAllExpandedStates();
this.variableSnapshot=cur; this.loadVariables(); this.restoreAllExpandedStates(keep); this.expandChangedKeys(changed);
}
}catch{}
}
loadVariables(){
['character','global'].forEach(t=>{
this.renderVariables(t);
$(`#${t}-variables-section [data-act="collapse"] i`).removeClass('fa-chevron-up').addClass('fa-chevron-down');
});
}
renderVariables(t){
const c=$(`#${t}-variables-list`).empty(), s=this.store(t), root=Object.entries(s);
if(!root.length) c.append('<div class="vm-empty-message">暂无变量</div>');
else root.forEach(([k,v])=> c.append(this.createVariableItem(t,k,v,0,[k])));
}
createVariableItem(t,k,v,l=0,fullPath=[]){
const parsed=this.parseValue(v), hasChildren=typeof parsed==='object' && parsed!==null;
const disp = l===0? this.formatTopLevelValue(v) : this.formatValue(v);
const ruleNode=getRuleNodeByPath(fullPath);
return $(`<div class="vm-item ${l>0?'vm-tree-level-var':''}" data-key="${k}" data-type="${t||''}" ${l>0?`data-level="${l}"`:''} data-path="${this.escape(pathKey(fullPath))}">
<div class="vm-item-header">
<div class="vm-item-name vm-item-name-visible">${this.escape(k)}${badgesHtml(ruleNode)}<span class="vm-item-separator">:</span></div>
<div class="vm-tree-value">${disp}</div>
<div class="vm-item-controls">${this.createButtons()}</div>
</div>
${hasChildren?`<div class="vm-item-content">${this.renderChildren(parsed,l+1,fullPath)}</div>`:''}
</div>`);
}
createButtons(){
return [
['edit','fa-edit','编辑'],
['add-child','fa-plus-circle','添加子变量'],
['copy','fa-eye-dropper','复制(长按: 宏,单击: 变量路径)'],
['delete','fa-trash','删除'],
].map(([act,ic,ti])=>`<button class="vm-btn" data-act="${act}" title="${ti}"><i class="fa-solid ${ic}"></i></button>`).join('');
}
createInlineForm(t,target,fs){
const fid=`inline-form-${Date.now()}`;
const inf=$(`
<div class="vm-inline-form" id="${fid}" data-type="${t}">
<div class="vm-form-row"><label class="vm-form-label">名称:</label><input type="text" class="vm-input vm-form-input inline-name" placeholder="变量名称"></div>
<div class="vm-form-row"><label class="vm-form-label">值:</label><textarea class="vm-textarea vm-form-input inline-value" placeholder="变量值 (支持JSON格式)"></textarea></div>
<div class="vm-form-buttons">
<button class="vm-btn" data-act="inline-save"><i class="fa-solid fa-floppy-disk"></i>保存</button>
<button class="vm-btn" data-act="inline-cancel">取消</button>
</div>
</div>`);
this.state.currentInlineForm?.remove();
target.after(inf); this.state.currentInlineForm=inf; this.state.formState={...fs,formId:fid,targetItem:target};
const ta=inf.find('.inline-value'); ta.on('input',()=>this.autoResizeTextarea(ta));
setTimeout(()=>{ inf.addClass('active'); inf.find('.inline-name').focus(); },10);
return inf;
}
renderChildren(obj,level,parentPath){ return Object.entries(obj).map(([k,v])=> this.createVariableItem(null,k,v,level,[...(parentPath||[]),k])[0].outerHTML).join(''); }
handleTouch(e){
if($(e.target).closest('.vm-item-controls').length) return;
e.stopPropagation();
const item=$(e.currentTarget).closest('.vm-item'); $('.vm-item').removeClass('touched'); item.addClass('touched');
this.clearTouchTimer(item);
const t=setTimeout(()=>{ item.removeClass('touched'); this.state.timers.touch.delete(item[0]); },CONFIG.touchTimeout);
this.state.timers.touch.set(item[0],t);
}
clearTouchTimer(i){ const t=this.state.timers.touch.get(i[0]); if(t){ clearTimeout(t); this.state.timers.touch.delete(i[0]); } }
handleItemClick(e){
if($(e.target).closest('.vm-item-controls').length) return;
e.stopPropagation();
$(e.currentTarget).closest('.vm-item').toggleClass('expanded');
}
async writeClipboard(txt){
try{
if(navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText(txt);
else { const ta=document.createElement('textarea'); Object.assign(ta.style,{position:'fixed',top:0,left:0,width:'2em',height:'2em',padding:0,border:'none',outline:'none',boxShadow:'none',background:'transparent'}); ta.value=txt; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }
return true;
}catch{ return false; }
}
handleCopy(e,longPress){
const item=$(e.target).closest('.vm-item'), path=this.getItemPath(item), t=this.getVariableType(item), level=parseInt(item.attr('data-level'))||0;
const formatted=this.formatPath(t,path); let cmd='';
if(longPress){
if(t==='character'){
cmd = level===0 ? `{{getvar::${path[0]}}}` : `{{xbgetvar::${formatted}}}`;
}else{
cmd = `{{getglobalvar::${path[0]}}}`;
if(level>0) toastr.info('全局变量宏暂不支持子路径,已复制顶级变量');
}
}else cmd=formatted;
(async()=> (await this.writeClipboard(cmd)) ? toastr.success(`已复制: ${cmd}`) : toastr.error('复制失败'))();
}
editAction(item,action,type,path){
const inf=this.createInlineForm(type,item,{action,path,type});
if(action==='edit'){
const v=this.getValueByPath(type,path);
setTimeout(()=>{
inf.find('.inline-name').val(path[path.length-1]);
const ta=inf.find('.inline-value');
const fill=(val)=> Array.isArray(val)? (val.length===1 ? String(val[0]??'') : JSON.stringify(val,null,2)) : (val&&typeof val==='object'? JSON.stringify(val,null,2) : String(val??''));
ta.val(fill(v)); this.autoResizeTextarea(ta);
},50);
}else if(action==='addChild'){
inf.find('.inline-name').attr('placeholder',`为 "${path.join('.')}" 添加子变量名称`);
inf.find('.inline-value').attr('placeholder','子变量值 (支持JSON格式)');
}
}
handleDelete(_item,t,path){
const n=path[path.length-1];
if(!confirm(`确定要删除 "${n}" 吗?`)) return;
this.withGlobalRefresh(()=> this.deleteByPathSilently(t,path));
toastr.success('变量已删除');
}
refreshAndKeep(t,states){ this.vt(t).save(); this.loadVariables(); this.updateSnapshot(); states && this.restoreExpandedStates(t,states); }
withPreservedExpansion(t,fn){ const s=this.saveExpandedStates(t); fn(); this.refreshAndKeep(t,s); }
withGlobalRefresh(fn){ const s=this.saveAllExpandedStates(); fn(); this.loadVariables(); this.updateSnapshot(); this.restoreAllExpandedStates(s); }
handleInlineSave(form){
if(this.savingInProgress) return; this.savingInProgress=true;
try{
if(!form?.length) return toastr.error('表单未找到');
const rawName=form.find('.inline-name').val();
const rawValue=form.find('.inline-value').val();
const name= typeof rawName==='string'? rawName.trim() : String(rawName ?? '').trim();
const value= typeof rawValue==='string'? rawValue.trim() : String(rawValue ?? '').trim();
const type=form.data('type');
if(!name) return form.find('.inline-name').focus(), toastr.error('请输入变量名称');
const val=this.processValue(value), {action,path}=this.state.formState;
this.withPreservedExpansion(type,()=>{
if(action==='addChild') {
this.setValueByPath(type,[...path,name],val);
} else if(action==='edit'){
const old=path[path.length-1];
if(name!==old){
this.deleteByPathSilently(type,path);
if(path.length===1) {
const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val;
this.vt(type).setter(name,toSave);
} else {
this.setValueByPath(type,[...path.slice(0,-1),name],val);
}
} else {
this.setValueByPath(type,path,val);
}
} else {
const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val;
this.vt(type).setter(name,toSave);
}
});
this.hideInlineForm(); toastr.success('变量已保存');
}catch(e){ toastr.error('JSON格式错误: '+e.message); }
finally{ this.savingInProgress=false; }
}
hideInlineForm(){ if(this.state.currentInlineForm){ this.state.currentInlineForm.removeClass('active'); setTimeout(()=>{ this.state.currentInlineForm?.remove(); this.state.currentInlineForm=null; },200);} this.state.formState={}; }
showAddForm(t){
this.hideInlineForm();
$(`#${t}-vm-add-form`).addClass('active');
const ta = $(`#${t}-vm-value`);
$(`#${t}-vm-name`).val('').attr('placeholder','变量名称').focus();
ta.val('').attr('placeholder','变量值 (支持JSON格式)');
if(!ta.data('auto-resize-bound')){ ta.on('input',()=>this.autoResizeTextarea(ta)); ta.data('auto-resize-bound',true); }
}
hideAddForm(t){ $(`#${t}-vm-add-form`).removeClass('active'); $(`#${t}-vm-name, #${t}-vm-value`).val(''); this.state.formState={}; }
saveAddVariable(t){
if(this.savingInProgress) return; this.savingInProgress=true;
try{
const rawN=$(`#${t}-vm-name`).val();
const rawV=$(`#${t}-vm-value`).val();
const n= typeof rawN==='string' ? rawN.trim() : String(rawN ?? '').trim();
const v= typeof rawV==='string' ? rawV.trim() : String(rawV ?? '').trim();
if(!n) return toastr.error('请输入变量名称');
const val=this.processValue(v);
this.withPreservedExpansion(t,()=> {
const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val;
this.vt(t).setter(n,toSave);
});
this.hideAddForm(t); toastr.success('变量已保存');
}catch(e){ toastr.error('JSON格式错误: '+e.message); }
finally{ this.savingInProgress=false; }
}
getValueByPath(t,p){ if(p.length===1) return this.vt(t).getter(p[0]); let v=this.parseValue(this.vt(t).getter(p[0])); p.slice(1).forEach(k=> v=v?.[k]); return v; }
setValueByPath(t,p,v){
if(p.length===1){
const toSave = (typeof v==='object' && v!==null) ? JSON.stringify(v) : v;
this.vt(t).setter(p[0], toSave);
return;
}
let root=this.parseValue(this.vt(t).getter(p[0])); if(typeof root!=='object'||root===null) root={};
let cur=root; p.slice(1,-1).forEach(k=>{ if(typeof cur[k]!=='object'||cur[k]===null) cur[k]={}; cur=cur[k]; });
cur[p[p.length-1]]=v; this.vt(t).setter(p[0], JSON.stringify(root));
}
deleteByPathSilently(t,p){
if(p.length===1){ delete this.store(t)[p[0]]; return; }
let root=this.parseValue(this.vt(t).getter(p[0])); if(typeof root!=='object'||root===null) return;
let cur=root; p.slice(1,-1).forEach(k=>{ if(typeof cur[k]!=='object'||cur[k]===null) cur[k]={}; cur=cur[k]; });
delete cur[p[p.length-1]]; this.vt(t).setter(p[0], JSON.stringify(root));
}
formatPath(t,path){
if(!Array.isArray(path)||!path.length) return '';
let out=String(path[0]), cur=this.parseValue(this.vt(t).getter(path[0]));
for(let i=1;i<path.length;i++){
const k=String(path[i]), isNum=/^\d+$/.test(k);
if(Array.isArray(cur) && isNum){ out+=`[${Number(k)}]`; cur=cur?.[Number(k)]; }
else { out+=`.`+k; cur=cur?.[k]; }
}
return out;
}
getVariableType(it){ return it.data('type') || (it.closest('.vm-section').attr('id').includes('character')?'character':'global'); }
getItemPath(i){ const p=[]; let c=i; while(c.length&&c.hasClass('vm-item')){ const k=c.data('key'); if(k!==undefined) p.unshift(String(k)); if(!c.attr('data-level')) break; c=c.parent().closest('.vm-item'); } return p; }
parseValue(v){ try{ return typeof v==='string'? JSON.parse(v) : v; }catch{ return v; } }
processValue(v){ if(typeof v!=='string') return v; const s=v.trim(); return (s.startsWith('{')||s.startsWith('['))? JSON.parse(s) : v; }
formatTopLevelValue(v){ const p=this.parseValue(v); if(typeof p==='object'&&p!==null){ const c=Array.isArray(p)? p.length : Object.keys(p).length; return `<span class="vm-object-count">[${c} items]</span>`; } return this.formatValue(p); }
formatValue(v){ if(v==null) return `<span class="vm-null-value">${v}</span>`; const e=this.escape(String(v)); return `<span class="vm-formatted-value">${e.length>50? e.substring(0,50)+'...' : e}</span>`; }
escape(t){ const d=document.createElement('div'); d.textContent=t; return d.innerHTML; }
autoResizeTextarea(ta){ if(!ta?.length) return; const el=ta[0]; el.style.height='auto'; const sh=el.scrollHeight, max=Math.min(300,window.innerHeight*0.4), fh=Math.max(60,Math.min(max,sh+4)); el.style.height=fh+'px'; el.style.overflowY=sh>max-4?'auto':'hidden'; }
searchVariables(t,q){ const l=q.toLowerCase().trim(); $(`#${t}-variables-list .vm-item`).each(function(){ $(this).toggle(!l || $(this).text().toLowerCase().includes(l)); }); }
collapseAll(t){ const items=$(`#${t}-variables-list .vm-item`), icon=$(`#${t}-variables-section [data-act="collapse"] i`); const any=items.filter('.expanded').length>0; items.toggleClass('expanded',!any); icon.toggleClass('fa-chevron-up',!any).toggleClass('fa-chevron-down',any); }
clearAllVariables(t){
if(!confirm(`确定要清除所有${t==='character'?'角色':'全局'}变量吗?`)) return;
this.withPreservedExpansion(t,()=>{ const s=this.store(t); Object.keys(s).forEach(k=> delete s[k]); });
toastr.success('变量已清除');
}
async importVariables(t){
const inp=document.createElement('input'); inp.type='file'; inp.accept='.json';
inp.onchange=async(e)=>{
try{
const tgt=e.target;
const file = (tgt && 'files' in tgt && tgt.files && tgt.files[0]) ? tgt.files[0] : null;
if(!file) throw new Error('未选择文件');
const txt=await file.text(), v=JSON.parse(txt);
this.withPreservedExpansion(t,()=> {
Object.entries(v).forEach(([k,val])=> {
const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val;
this.vt(t).setter(k,toSave);
});
});
toastr.success(`成功导入 ${Object.keys(v).length} 个变量`);
}catch{ toastr.error('文件格式错误'); }
};
inp.click();
}
exportVariables(t){
const v=this.store(t), b=new Blob([JSON.stringify(v,null,2)],{type:'application/json'}), a=document.createElement('a');
a.href=URL.createObjectURL(b); a.download=`${t}-variables-${new Date().toISOString().split('T')[0]}.json`; a.click();
toastr.success('变量已导出');
}
saveExpandedStates(t){ const s=new Set(); $(`#${t}-variables-list .vm-item.expanded`).each(function(){ const k=$(this).data('key'); if(k!==undefined) s.add(String(k)); }); return s; }
saveAllExpandedStates(){ return { character:this.saveExpandedStates('character'), global:this.saveExpandedStates('global') }; }
restoreExpandedStates(t,s){ if(!s?.size) return; setTimeout(()=>{ $(`#${t}-variables-list .vm-item`).each(function(){ const k=$(this).data('key'); if(k!==undefined && s.has(String(k))) $(this).addClass('expanded'); }); },50); }
restoreAllExpandedStates(st){ Object.entries(st).forEach(([t,s])=> this.restoreExpandedStates(t,s)); }
toggleEnabled(en){
const s=this.getSettings(); s.enabled=this.state.isEnabled=en; saveSettingsDebounced(); this.syncCheckbox();
en ? (this.enable(),this.open()) : this.disable();
}
createPerMessageBtn(messageId){
const btn=document.createElement('div');
btn.className='mes_btn mes_variables_panel';
btn.title='变量面板';
btn.dataset.mid=messageId;
btn.innerHTML='<i class="fa-solid fa-database"></i>';
btn.addEventListener('click',(e)=>{ e.preventDefault(); e.stopPropagation(); this.open(); });
return btn;
}
addButtonToMessage(messageId){
const msg=$(`#chat .mes[mesid="${messageId}"]`);
if(!msg.length || msg.find('.mes_btn.mes_variables_panel').length) return;
const btn=this.createPerMessageBtn(messageId);
const appendToFlex=(m)=>{ const flex=m.find('.flex-container.flex1.alignitemscenter'); if(flex.length) flex.append(btn); };
if(typeof window['registerButtonToSubContainer']==='function'){
const ok=window['registerButtonToSubContainer'](messageId,btn);
if(!ok) appendToFlex(msg);
} else appendToFlex(msg);
}
addButtonsToAllMessages(){ $('#chat .mes').each((_,el)=>{ const mid=el.getAttribute('mesid'); if(mid) this.addButtonToMessage(mid); }); }
removeAllMessageButtons(){ $('#chat .mes .mes_btn.mes_variables_panel').remove(); }
installMessageButtons(){
const delayedAdd=(id)=> setTimeout(()=>{ if(id!=null) this.addButtonToMessage(id); },120);
const delayedScan=()=> setTimeout(()=> this.addButtonsToAllMessages(),150);
this.removeMessageButtonsListeners();
const idFrom=(d)=> typeof d==='object' ? (d.messageId||d.id) : d;
if (!this.msgEvents) this.msgEvents = createModuleEvents('variablesPanel:messages');
this.msgEvents.onMany([
event_types.USER_MESSAGE_RENDERED,
event_types.CHARACTER_MESSAGE_RENDERED,
event_types.MESSAGE_RECEIVED,
event_types.MESSAGE_UPDATED,
event_types.MESSAGE_SWIPED,
event_types.MESSAGE_EDITED
].filter(Boolean), (d) => delayedAdd(idFrom(d)));
this.msgEvents.on(event_types.MESSAGE_SENT, debounce(() => delayedScan(), 300));
this.msgEvents.on(event_types.CHAT_CHANGED, () => delayedScan());
this.addButtonsToAllMessages();
}
removeMessageButtonsListeners(){
if (this.msgEvents) {
this.msgEvents.cleanup();
}
}
removeMessageButtons(){ this.removeMessageButtonsListeners(); this.removeAllMessageButtons(); }
normalizeStore(t){
const s=this.store(t); let changed=0;
for(const[k,v] of Object.entries(s)){
if(typeof v==='object' && v!==null){
try{ s[k]=JSON.stringify(v); changed++; }catch{}
}
}
if(changed) this.vt(t).save?.();
}
}
let variablesPanelInstance=null;
export async function initVariablesPanel(){
try{
extension_settings.variables ??= { global:{} };
if(variablesPanelInstance) variablesPanelInstance.cleanup();
variablesPanelInstance=new VariablesPanel();
await variablesPanelInstance.init();
return variablesPanelInstance;
}catch(e){
console.error(`[${CONFIG.extensionName}] 加载失败:`,e);
toastr?.error?.('Variables Panel加载失败');
throw e;
}
}
export function getVariablesPanelInstance(){ return variablesPanelInstance; }
export function cleanupVariablesPanel(){ if(variablesPanelInstance){ variablesPanelInstance.removeMessageButtons(); variablesPanelInstance.cleanup(); variablesPanelInstance=null; } }