681 lines
37 KiB
JavaScript
681 lines
37 KiB
JavaScript
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,'"'), 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; } }
|