diff --git a/tests/test_workspace_bindings.mjs b/tests/test_workspace_bindings.mjs index 3dcb22e..54fb7a0 100644 --- a/tests/test_workspace_bindings.mjs +++ b/tests/test_workspace_bindings.mjs @@ -16,13 +16,13 @@ add(553,'Power Lora Loader (rgthree)',[ {name:'lora_7',value:{on:true,lora:'old7.safetensors',strength:1}} ]); add(360,'Power Lora Loader (rgthree)'); -const preset=add(524,'AdachiGroupPresets'); +const preset=add(524,'AdachiGroupPresets');preset.mode=0; const applied=[]; -preset.adachiGroupPresets={model:{node:preset,data:{activeId:'anima',presets:[{id:'txt',name:'anima文生图'},{id:'anima',name:'anima修图'},{id:'wai',name:'wai修图'}]},apply:id=>applied.push(id)}}; +preset.adachiGroupPresets={model:{node:preset,data:{activeId:'anima',presets:[{id:'txt',name:'anima文生图'},{id:'anima',name:'anima修图'},{id:'wai',name:'wai修图'}]},apply:id=>{applied.push(id);preset.adachiGroupPresets.model.data.activeId=id;}}}; const graph={_nodes:nodes,getNodeById:id=>nodes.find(n=>String(n.id)===String(id)),links:{}}; const bindings=new WorkspaceBindings({graph,sampler}); assert.equal(bindings.profiles.length,3); -assert.equal(bindings.profileId,'wai'); +assert.equal(bindings.profileId,'anima');bindings.selectProfile('wai'); let result=bindings.getExecutionBinding(); assert.deepEqual(result.samplerInputs,{model:['518',0],vae:['518',2],positive:['521',0],negative:['520',0]}); assert.deepEqual(result.inputOverrides['521'],{clip:['518',1]}); @@ -37,10 +37,21 @@ assert.equal(result.inputOverrides['553'].lora_7.on,false,'old enabled LoRA rows bindings.settings.positive='new prompt'; assert.equal(bindings.getExecutionBinding().inputOverrides['521'].text,'new prompt'); bindings.selectProfile('anima'); -assert.deepEqual(applied,['anima']); +assert.deepEqual(applied,['wai','anima']); assert.deepEqual(bindings.getExecutionBinding().samplerInputs.model,['359',0]); assert.deepEqual(bindings.getExecutionBinding().inputOverrides['547'].clip,['357',0]); bindings.selectProfile('wai'); assert.equal(bindings.settings.positive,'new prompt'); assert.equal(ckpt.widgets[0].value,'wai.safetensors','read-only discovery must not change a model widget'); console.log('Workspace bindings: existing presets, WAI/Anima mapping, no LoRA, LoRA strengths, prompt edits and profile isolation passed'); + +preset.mode=4; +const ignored=new WorkspaceBindings({graph,sampler}); +assert.equal(ignored.controller,undefined,'ignored group controller must not create preset buttons'); +assert.deepEqual(ignored.profiles.map(p=>p.id),['current']); +preset.mode=0; +preset.adachiGroupPresets.model.data.presets.push({id:'fourth',name:'custom branch'}); +ignored.refreshProfiles();assert.equal(ignored.profiles.length,4,'preset count comes from active controller'); +console.log('Group controller: ignored/missing fallback and dynamic preset count pass'); + +preset.adachiGroupPresets.model.data.activeId='anima';bindings.refreshProfiles();assert.equal(bindings.profileId,'anima','external preset switching is reflected'); diff --git a/tests/test_workspace_routes.mjs b/tests/test_workspace_routes.mjs new file mode 100644 index 0000000..8cdad72 --- /dev/null +++ b/tests/test_workspace_routes.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict';import {conditioningSource} from '../web/workspace_routes.js'; +const nodes=[];const add=(id,type,inputs=[],value)=>{const n={id,type,mode:0,inputs,widgets:[{value}]};nodes.push(n);return n}; +const a=add(1,'CLIPTextEncode'),b=add(2,'CLIPTextEncode');const sa=add(3,'SetNode',[{name:'x',link:1}],'p');const ga=add(4,'GetNode',[],'p');const sb=add(5,'SetNode',[{name:'x',link:2}],'p2');const gb=add(6,'GetNode',[],'p2');const sw=add(7,'AdachiExclusiveSwitch',[{name:'input_a',link:3},{name:'input_b',link:4}]);const sampler=add(8,'AdachiWebUIInpaint',[{name:'positive',link:5},{name:'negative',link:6}]); +const links={1:{origin_id:1,origin_slot:0},2:{origin_id:2,origin_slot:0},3:{origin_id:4,origin_slot:0},4:{origin_id:6,origin_slot:0},5:{origin_id:7,origin_slot:0},6:{origin_id:2,origin_slot:0}}; +const g={_nodes:nodes,links,getNodeById:id=>nodes.find(n=>n.id==id)};nodes.forEach(n=>n.graph=g); +assert.equal(conditioningSource(g,sampler,'positive'),null,'ambiguous branches must not be guessed'); +assert.equal(conditioningSource(g,sampler,'positive',new Map([[sb,4]])),a); +assert.equal(conditioningSource(g,sampler,'negative'),b); +sa.inputs[0].link=3;assert.equal(conditioningSource(g,sampler,'positive',new Map([[sb,4]])),null,'cycle must stop'); +console.log('Conditioning discovery: actual sampler route, Get/Set, preset state, ambiguity and cycles pass'); diff --git a/web/workspace_bindings.js b/web/workspace_bindings.js index ae5a77d..8f40aa0 100644 --- a/web/workspace_bindings.js +++ b/web/workspace_bindings.js @@ -1,3 +1,4 @@ +import { conditioningSource } from './workspace_routes.js'; // Bind the workbench to existing graph widgets; no model or LoRA is loaded here. const widget = (node, name) => node?.widgets?.find(w => w.name === name); const nodeList = graph => Array.from(graph?._nodes || []); @@ -12,14 +13,14 @@ export class WorkspaceBindings { constructor({graph, sampler, onChange = () => {}, markChanged = () => {}}) { Object.assign(this, {graph, sampler, onChange, markChanged}); this.nodes = nodeList(graph); - this.controller = this.nodes.find(n => n.type === 'AdachiGroupPresets')?.adachiGroupPresets?.model; + this.controller = this.nodes.find(n => n.type === 'AdachiGroupPresets' && n.mode === 0)?.adachiGroupPresets?.model; this.owner = this.controller?.node || sampler; this.owner.properties ??= {}; this.owner.properties[stateKey] ??= {profiles:{}}; this.catalog = {checkpoints:[], loras:[]}; this.profiles = this.controller?.data?.presets?.map(p => ({id:p.id, name:p.name, preset:p})) || []; if (!this.profiles.length) this.profiles = [{id:'current',name:'当前工作流'}]; - this.profileId = this.profiles.find(p => /wai/i.test(p.name))?.id || this.controller?.data?.activeId || this.profiles[0].id; + this.profileId = this.controller?.data?.activeId || this.profiles[0].id; this.resolve(); } resolve() { @@ -47,6 +48,14 @@ export class WorkspaceBindings { this.vae=this.model?.type==='CheckpointLoaderSimple'?this.model:null;this.vaeSlot=2; this.lora=this.nodes.find(n=>n.type==='Power Lora Loader (rgthree)' && n.mode===0) || this.nodes.find(n=>n.type==='LoraLoader' && n.mode===0); } + const planned = this.controller?.planStates && profile.preset ? this.controller.planStates(profile.preset).plan : new Map(); + this.promptSources = {}; + for (const kind of ['positive','negative']) { + const traced = conditioningSource(this.graph,this.sampler,kind,planned); + if (traced) { this[kind] = traced; this.promptSources[kind] = '实际采样器连线'; } + else if (this.isTemplate && profile.preset) this.promptSources[kind] = '当前模板方案'; + else { this[kind] = null; this.promptSources[kind] = '由原工作流控制'; } + } const all=this.owner.properties[stateKey].profiles; all[this.profileId] ??= {loras:[]}; this.settings=all[this.profileId]; @@ -54,19 +63,40 @@ export class WorkspaceBindings { return profile; } changed() { this.markChanged(); this.onChange(this); } + refreshProfiles() { + this.nodes=nodeList(this.graph); + const controller = this.nodes.find(n=>n.type==='AdachiGroupPresets' && n.mode===0)?.adachiGroupPresets?.model; + const signature = JSON.stringify([controller?.node?.id, controller?.data?.activeId, controller?.data?.presets?.map(p=>[p.id,p.name])]); + if(signature===this.presetSignature)return; + this.presetSignature=signature; + this.controller=controller; + this.owner=controller?.node || this.sampler; + this.owner.properties ??= {}; + this.owner.properties[stateKey] ??= {profiles:{}}; + this.profiles=controller?.data?.presets?.map(p=>({id:p.id,name:p.name,preset:p})) || []; + if(!this.profiles.length)this.profiles=[{id:'current',name:'当前工作流'}]; + this.profileId=controller?.data?.activeId || (this.profiles.some(p=>p.id===this.profileId)?this.profileId:this.profiles[0].id); + this.resolve();this.render();this.onChange(this); + } selectProfile(id) { if (!this.profiles.some(p=>p.id===id)) throw Error('分组预设不存在'); this.profileId=id; this.controller?.apply(id); this.resolve(); this.render(); this.changed(); } - getExecutionBinding(id=this.profileId) { - if (id!==this.profileId) { this.profileId=id; this.resolve(); } + getExecutionBinding(id) { + this.refreshProfiles(); + if (id!==undefined && id!==this.profileId) { this.profileId=id; this.resolve(); } const binding={enableNodes:[],samplerInputs:{},inputOverrides:{},removeInputs:{}}; const add=node=>{if(node && !binding.enableNodes.includes(node))binding.enableNodes.push(node);}; // Generic graphs keep their original routing unless complete core // model/conditioning nodes can be identified. - if (!this.model || !this.clip || !this.vae || this.positive?.type!=='CLIPTextEncode' || this.negative?.type!=='CLIPTextEncode') return binding; + if (!this.model || !this.clip || !this.vae || this.positive?.type!=='CLIPTextEncode' || this.negative?.type!=='CLIPTextEncode') { + for (const kind of ['positive','negative']) if(this[kind] && Object.hasOwn(this.settings,kind)) { + add(this[kind]); binding.inputOverrides[String(this[kind].id)] = {text:this.settings[kind]}; + } + return binding; + } for(const n of [this.model,this.clip,this.vae,this.positive,this.negative])add(n); let model=ref(this.model),clip=ref(this.clip,this.clipSlot); const selected=this.settings.loras.filter(l=>l.name && l.on!==false); @@ -112,7 +142,9 @@ export class WorkspaceBindings { const select=(values,current,change)=>{const s=el('select');for(const [value,label] of values){const o=el('option',label);o.value=value;s.append(o);}s.value=current;s.onchange=()=>change(s.value);return s;}; this.root.replaceChildren(); const presets=el('div');presets.className='aw-presets'; - for(const p of this.profiles){const b=el('button',p.name);b.type='button';b.setAttribute('aria-pressed',String(p.id===this.profileId));b.onclick=()=>this.selectProfile(p.id);presets.append(b);} + const controllerActive = this.controller && this.controller.node.mode === 0; + if (!controllerActive) {const warning=el('p','未启用组忽略预设节点,当前使用原工作流设置。');warning.className='aw-profile-warning';warning.setAttribute('role','status');this.root.append(warning);} + for(const p of controllerActive ? this.profiles : []){const b=el('button',p.name);b.type='button';b.setAttribute('aria-pressed',String(p.id===this.profileId));b.onclick=()=>this.selectProfile(p.id);presets.append(b);} this.root.append(presets); const details=el('details'),summary=el('summary','主模型与 LoRA');details.append(summary);details.open=!!this.detailsOpen;details.ontoggle=()=>{this.detailsOpen=details.open;}; const modelWidget=widget(this.model,'ckpt_name') || widget(this.model,'unet_name'); @@ -135,12 +167,17 @@ export class WorkspaceBindings { if(this.catalogError)details.append(el('small',this.catalogError)); this.root.append(details); for(const [kind,node,label] of [['positive',this.positive,'正面提示词'],['negative',this.negative,'负面提示词']]) { - const w=widget(node,'text');if(!w)continue; + const w=widget(node,'text'); + if(!w){const source=el('p',label+':由原工作流控制');source.title='未找到唯一的 CLIP 编码节点,请在原工作流调整该条件';this.root.append(source);continue;} + const linked=node.inputs?.some(i=>i.name==='text' && i.link!=null); const text=el('textarea');text.rows=kind==='positive'?6:3; - text.value=this.settings[kind] ?? w.value ?? ''; + text.value=this.settings[kind] ?? (linked ? '' : w.value) ?? ''; text.placeholder=node.inputs?.some(i=>i.name==='text' && i.link!=null)?'保持原工作流提示词;输入后以这里为准':''; text.oninput=()=>{this.settings[kind]=text.value;this.changed();}; - this.root.append(row(label,text)); + text.setAttribute('aria-label',label); + const field=row(label,text);field.title=`${this.promptSources[kind]} · ${node.title || node.type} #${node.id}。${linked ? '文本来自上游节点;留空且未编辑时沿用上游,输入后覆盖。' : '编辑后作为本方案的提示词。'}`; + const reset=el('button','↶');reset.type='button';reset.title='恢复工作流中的'+label;reset.onclick=()=>{delete this.settings[kind];this.render();this.changed();};field.append(reset); + this.root.append(field); } } destroy() {this.destroyed=true;this.root?.remove();} diff --git a/web/workspace_routes.js b/web/workspace_routes.js new file mode 100644 index 0000000..29b8f93 --- /dev/null +++ b/web/workspace_routes.js @@ -0,0 +1,35 @@ +const linkAt = (graph, input) => input?.link == null ? null + : graph.getLink?.(input.link) ?? graph.links?.get?.(input.link) ?? graph.links?.[input.link]; +// Follow the actual sampler conditioning inputs, including KJ Get/Set routes. +// Ambiguous processing graphs are left under the workflow's control. +export function conditioningSource(graph, sampler, inputName, modes = new Map()) { + const mode = node => modes.get(node) ?? node.mode ?? 0; + const visit = (node, slot, seen = new Set()) => { + if (!node || seen.has(node) || mode(node) === 2) return null; + seen = new Set(seen); seen.add(node); + const follow = input => { + const link = linkAt(node.graph || graph, input); + return link ? visit((node.graph || graph).getNodeById(link.origin_id), link.origin_slot, seen) : null; + }; + if (node.type === 'AdachiExclusiveSwitch') { + const sources = (node.inputs || []).filter(i=>['input_a','input_b'].includes(i.name)).map(follow).filter(Boolean); + return sources.length === 1 ? sources[0] : null; + } + if (node.type === 'GetNode') { + if (mode(node) !== 0) return null; + const name = node.widgets?.[0]?.value; + const setter = node.findSetter?.(node.graph || graph) || Array.from(graph._nodes || []).find(n=>n.type === 'SetNode' && n.widgets?.[0]?.value === name); + return setter ? visit(setter,slot,seen) : null; + } + if (node.type === 'SetNode' || node.type === 'Reroute') { + if (mode(node) !== 0) return null; + return follow(node.inputs?.[0]); + } + if (node.type === 'CLIPTextEncode') return mode(node) === 0 ? node : null; + const resolved = node.resolveVirtualOutput?.(slot); + if (resolved?.node) return visit(resolved.node,resolved.slot ?? 0,seen); + return null; + }; + const link = linkAt(graph, sampler.inputs?.find(i=>i.name===inputName)); + return link ? visit(graph.getNodeById(link.origin_id),link.origin_slot) : null; +}