@@ -151,6 +151,7 @@ async function reload() {
S . vault = await call ( 'vault:get' ) ;
I18N . setLanguage ( settings ( ) . language , navigator . language ) ;
applyStatic ( ) ;
initAi ( ) ;
const b = $ ( '#vaultBadge' ) ;
b . innerHTML = S . vault . encrypted ? ` ${ ICONS . lock } <span> ${ T ( 'Vault encrypted' ) } </span> ` : ` ${ ICONS . unlock } <span> ${ T ( 'Vault not encrypted' ) } </span> ` ;
b . title = S . vault . encrypted ? T ( 'Data is encrypted with the system keyring.' ) : T ( 'No system keyring available (e.g. install gnome-keyring/kwallet).' ) ;
@@ -1053,7 +1054,7 @@ async function viewSettings(page) {
} ) ;
// ---- Support-Assistent (Ollama)
const aiCard = h ( ` <div class="settings-card"><h3> ${ T ( 'Support assistant' ) } </h3><p style="color:var(--muted);margin-top:0"> ${ T ( 'Helps with errors in the Linux console via a local <b>Ollama</b> instance. Open it in a terminal tab with the <i>Assistant</i> button. The latest terminal output is sent to the Ollama server as context.' ) } </p></div> ` ) ;
const aiCard = h ( ` <div class="settings-card"><h3> ${ T ( 'Support assistant' ) } </h3><p style="color:var(--muted);margin-top:0"> ${ T ( 'Helps with errors in the Linux console, Docker, firewall and network via a local <b>Ollama</b> instance. Open it with the chat button in the title bar or in a tab. The content of the active tab is sent to the Ollama server as context. Chats are stored in the vault. ' ) } </p></div> ` ) ;
const aiUrl = field ( T ( 'Ollama server' ) , 'aiUrl' , st . aiUrl || 'http://localhost:11434' , { placeholder : 'http://localhost:11434' } ) ;
const aiModel = field ( T ( 'Model' ) , 'aiModel' , st . aiModel , { type : 'select' , options : [ [ st . aiModel || '' , st . aiModel || T ( '– select – ' ) ] ] } ) ;
const loadModels = async ( ) => {
@@ -1568,6 +1569,7 @@ function activateTab(id) {
} ) ;
$ ( '.tab.active' ) [ 0 ] ? . scrollIntoView ( { block : 'nearest' , inline : 'nearest' } ) ;
renderTree ( ) ;
if ( $ ( '#aiPanel' ) . classList . contains ( 'open' ) ) aiUpdateCtx ( ) ;
}
function closeTab ( id ) {
const i = S . tabs . findIndex ( ( t ) => t . id === id ) ;
@@ -1592,20 +1594,14 @@ class TerminalSession {
<button class="btn ghost sm" data-a="sftp" title=" ${ T ( 'SFTP for this host' ) } "> ${ ICONS . folder } SFTP</button>
${ host . execCommand ? '' : ` <button class="btn ghost sm" data-a="docker" title=" ${ T ( 'Docker containers' ) } "> ${ ICONS . docker } Docker</button><button class="btn ghost sm" data-a="firewall" title=" ${ T ( 'Firewall' ) } "> ${ ICONS . wall } ${ T ( 'Firewall' ) } </button><button class="btn ghost sm" data-a="network" title=" ${ T ( 'Network' ) } "> ${ ICONS . network } ${ T ( 'Network' ) } </button> ` }
<button class="btn ghost sm" data-a="snip" title="Snippets"> ${ ICONS . code } Snippets</button>
<button class="btn ghost sm" data-a="ai" title=" ${ T ( 'Support assistant (Ollama)' ) } "> ${ ICONS . ai } ${ T ( 'Assistant' ) } </button>
<button class="btn ghost sm" data-ai-toggle title=" ${ T ( 'Support assistant (Ollama)' ) } "> ${ ICONS . ai } ${ T ( 'Assistant' ) } </button>
<button class="btn ghost sm" data-a="find" title=" ${ T ( 'Search (Ctrl+Shift+F)' ) } "> ${ ICONS . search } </button>
</div>
<div class="term-wrap"><div class="term"></div>
<div class="findbar"><input placeholder=" ${ T ( 'Search …' ) } "/><button class="btn ghost sm" data-f="prev">↑</button><button class="btn ghost sm" data-f="next">↓</button><button class="btn ghost sm" data-f="close"> ${ ICONS . close } </button></div>
<div class="snip-panel"><div class="head">Snippets<button class="btn ghost sm" data-a="snipnew"> ${ ICONS . plus } </button></div><div class="items"></div></div>
<div class="ai-panel"><div class="head"> ${ T ( 'Support assistant' ) } <span><button class="btn ghost sm" data-a="aiclear" title=" ${ T ( 'New chat' ) } "> ${ ICONS . refresh } </button><button class="btn ghost sm" data-a="ai"> ${ ICONS . close } </button></span></div>
<div class="msgs"></div>
<form class="ask"><label class="check"><input type="checkbox" name="ctx" checked/> ${ T ( 'Include terminal output' ) } </label>
<textarea rows="3" placeholder=" ${ T ( 'Describe the problem … (Enter = send)' ) } "></textarea>
<div class="row"><button type="button" class="btn sm" data-a="aierr"> ${ T ( 'Explain last error' ) } </button><button class="btn primary sm" type="submit"> ${ T ( 'Send' ) } </button></div></form></div>
</div></div> ` ) ;
this . overlay = null ;
this . chat = [ ] ;
this . alive = false ;
this . initTerm ( ) ;
this . bindUi ( ) ;
@@ -1660,9 +1656,6 @@ class TerminalSession {
if ( a === 'network' ) openNetwork ( this . host ) ;
if ( a === 'snip' ) { $ ( '.snip-panel' , this . el ) . classList . toggle ( 'open' ) ; this . renderSnips ( ) ; this . doFit ( ) ; }
if ( a === 'snipnew' ) editSnippet ( ) ;
if ( a === 'ai' ) { $ ( '.ai-panel' , this . el ) . classList . toggle ( 'open' ) ; this . doFit ( ) ; if ( $ ( '.ai-panel' , this . el ) . classList . contains ( 'open' ) ) $ ( '.ai-panel textarea' , this . el ) . focus ( ) ; }
if ( a === 'aiclear' ) { api . call ( 'ai:stop' , this . id ) ; this . chat = [ ] ; $ ( '.ai-panel .msgs' , this . el ) . innerHTML = '' ; }
if ( a === 'aierr' ) this . askAi ( T ( 'Explain the last error in the terminal output and how to fix it.' ) ) ;
if ( a === 'find' ) this . toggleFind ( ) ;
const f = e . target . closest ( '[data-f]' ) ? . dataset . f ;
const inp = $ ( '.findbar input' , this . el ) ;
@@ -1670,16 +1663,6 @@ class TerminalSession {
if ( f === 'prev' ) this . search . findPrevious ( inp . value ) ;
if ( f === 'close' ) this . toggleFind ( false ) ;
} ) ;
const ta = $ ( '.ai-panel textarea' , this . el ) ;
$ ( '.ai-panel form' , this . el ) . onsubmit = ( e ) => { e . preventDefault ( ) ; if ( ta . value . trim ( ) ) { this . askAi ( ta . value . trim ( ) ) ; ta . value = '' ; } } ;
ta . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Enter' && ! e . shiftKey ) { e . preventDefault ( ) ; $ ( '.ai-panel form' , this . el ) . requestSubmit ( ) ; } } ) ;
$ ( '.ai-panel .msgs' , this . el ) . addEventListener ( 'click' , ( e ) => {
const b = e . target . closest ( '[data-cmd]' ) ; if ( ! b ) return ;
const code = b . closest ( '.code' ) . querySelector ( 'code' ) . textContent . replace ( /\n+$/ , '' ) ;
if ( b . dataset . cmd === 'copy' ) { api . call ( 'clipboard:write' , code ) ; toast ( T ( 'Copied' ) ) ; }
// Nur einfügen, nicht ausführen – der Nutzer prüft und bestätigt mit Enter
else { this . term . paste ( code ) ; this . term . focus ( ) ; }
} ) ;
$ ( '.findbar input' , this . el ) . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Enter' ) ( e . shiftKey ? this . search . findPrevious : this . search . findNext ) . call ( this . search , e . target . value ) ;
if ( e . key === 'Escape' ) this . toggleFind ( false ) ;
@@ -1699,37 +1682,12 @@ class TerminalSession {
}
// Letzte Zeilen des Terminals als Kontext für den Assistenten
termContext ( lines = 120 ) {
aiContext ( lines = 120 ) {
const b = this . term . buffer . active , out = [ ] ;
for ( let i = Math . max ( 0 , b . length - lines ) ; i < b . length ; i ++ ) out . push ( b . getLine ( i ) ? . translateToString ( true ) ? ? '' ) ;
return out . join ( '\n' ) . replace ( /\n{3,}/g , '\n\n' ) . trim ( ) ;
}
async askAi ( text ) {
const panel = $ ( '.ai-panel' , this . el ) , box = $ ( '.msgs' , panel ) ;
if ( ! panel . classList . contains ( 'open' ) ) { panel . classList . add ( 'open' ) ; this . doFit ( ) ; }
if ( ! settings ( ) . aiModel ) { toast ( T ( 'Please select an Ollama model in the settings first.' ) , 'error' ) ; return ; }
this . chat . push ( { role : 'user' , content : text } ) ;
box . append ( h ( ` <div class="m user"> ${ esc ( text ) } </div> ` ) ) ;
const out = h ( '<div class="m bot"><div class="spinner"></div></div>' ) ; box . append ( out ) ;
box . scrollTop = box . scrollHeight ;
const reply = { role : 'assistant' , content : '' } ;
this . aiUnsub ? . ( ) ;
this . aiUnsub = api . on ( 'ai:event' , ( cid , type , payload ) => {
if ( cid !== this . id ) return ;
const stick = box . scrollHeight - box . scrollTop - box . clientHeight < 40 ;
if ( type === 'data' ) { reply . content += payload ; out . innerHTML = aiMarkdown ( reply . content ) ; }
else {
this . aiUnsub ( ) ; this . aiUnsub = null ;
if ( type === 'error' ) { out . classList . add ( 'err' ) ; out . textContent = payload ; this . chat . pop ( ) ; }
else this . chat . push ( reply ) ;
}
if ( stick ) box . scrollTop = box . scrollHeight ;
} ) ;
try { await api . call ( 'ai:chat' , this . id , this . chat . slice ( - 20 ) , $ ( '[name=ctx]' , panel ) . checked ? this . termContext ( ) : '' ) ; }
catch ( e ) { this . aiUnsub ? . ( ) ; this . aiUnsub = null ; out . classList . add ( 'err' ) ; out . textContent = e . message ; this . chat . pop ( ) ; }
}
toggleFind ( open ) {
const fb = $ ( '.findbar' , this . el ) ;
const o = open ? ? ! fb . classList . contains ( 'open' ) ;
@@ -1802,7 +1760,7 @@ class TerminalSession {
this . term . write ( ` \r \n \x 1b[90m ${ T ( 'Connection closed. Press [Enter] to reconnect.' ) } \x 1b[0m \r \n ` ) ;
}
}
dispose ( ) { this . unsub ( ) ; this . aiUnsub ? . ( ) ; api . call ( 'ai:stop' , this . id ) . catch ( ( ) => { } ) ; this . ro . disconnect ( ) ; api . ssh . close ( this . id ) ; this . term . dispose ( ) ; }
dispose ( ) { this . unsub ( ) ; this . ro . disconnect ( ) ; api . ssh . close ( this . id ) ; this . term . dispose ( ) ; }
}
// Minimales Markdown für Assistenten-Antworten: Codeblöcke (mit Kopieren/Einfügen), Inline-Code, Fett
function aiMarkdown ( md ) {
@@ -1816,6 +1774,146 @@ function aiMarkdown(md) {
}
function openTerminal ( host ) { return new TerminalSession ( host ) ; }
// ============================================================ Support-Assistent (tabübergreifend, Chats im Tresor)
const AI = { chat : null , unsub : null , busy : false } ;
const aiChats = ( ) => ( S . vault . aiChats || = [ ] ) ;
const activeSession = ( ) => S . tabs . find ( ( t ) => t . id === S . active ) ;
function aiToggle ( open ) {
const p = $ ( '#aiPanel' ) , o = open ? ? ! p . classList . contains ( 'open' ) ;
p . classList . toggle ( 'open' , o ) ;
if ( o ) { aiRender ( ) ; $ ( 'textarea' , p ) . focus ( ) ; }
}
// Kontext aus dem aktiven Tab: Terminalausgabe, Docker-/Firewall-/Netzwerkansicht …
function aiContext ( ) {
const t = activeSession ( ) ;
if ( ! t ) return '' ;
const host = t . host ? ` , host ${ t . host . username ? t . host . username + '@' : '' } ${ t . host . address } ` : '' ;
const body = String ( ( t . aiContext ? t . aiContext ( ) : t . el . innerText ) || '' ) . trim ( ) ;
return body ? ` Active MrTerm tab: ${ t . title } ( ${ t . kind } ${ host } ) \n \n ${ body . slice ( - 12000 ) } ` : '' ;
}
function aiUpdateCtx ( ) {
const t = activeSession ( ) ;
$ ( '#aiPanel .ctx-name' ) . textContent = t ? t . title : T ( 'Vault (no context)' ) ;
}
function aiRender ( ) {
const p = $ ( '#aiPanel' ) , sel = $ ( '.chats' , p ) , box = $ ( '.msgs' , p ) ;
sel . innerHTML = '' ;
sel . append ( new Option ( T ( 'New chat' ) , '' ) ) ;
[ ... aiChats ( ) ] . sort ( ( a , b ) => b . updatedAt - a . updatedAt ) . forEach ( ( c ) => sel . append ( new Option ( c . title , c . id ) ) ) ;
sel . value = AI . chat ? . id && aiChats ( ) . includes ( AI . chat ) ? AI . chat . id : '' ;
box . innerHTML = '' ;
if ( ! AI . chat ? . messages . length ) box . append ( h ( ` <div class="hint"> ${ T ( 'Ask about an error or let the assistant explain what you see. The content of the active tab is sent as context.' ) } </div> ` ) ) ;
for ( const m of AI . chat ? . messages || [ ] ) {
if ( m . role === 'user' ) box . append ( h ( ` <div class="m user"> ${ m . tab ? ` <small> ${ esc ( m . tab ) } </small> ` : '' } ${ esc ( m . content ) } </div> ` ) ) ;
else { const d = h ( '<div class="m bot"></div>' ) ; d . innerHTML = aiMarkdown ( m . content ) ; box . append ( d ) ; }
}
$ ( '[data-ai=del]' , p ) . disabled = ! AI . chat ? . id || ! aiChats ( ) . includes ( AI . chat ) ;
aiUpdateCtx ( ) ;
box . scrollTop = box . scrollHeight ;
}
async function aiSave ( ) {
const c = AI . chat ;
if ( ! c ) return ;
if ( ! aiChats ( ) . includes ( c ) ) aiChats ( ) . push ( c ) ;
c . updatedAt = Date . now ( ) ;
try { await call ( 'vault:upsert' , 'aiChats' , c ) ; } catch { }
}
function aiSetBusy ( b ) {
AI . busy = b ;
const btn = $ ( '#aiPanel [type=submit]' ) ;
btn . textContent = b ? T ( 'Stop' ) : T ( 'Send' ) ;
btn . classList . toggle ( 'danger' , b ) ;
}
async function aiAsk ( text ) {
const p = $ ( '#aiPanel' ) ;
if ( ! p . classList . contains ( 'open' ) ) aiToggle ( true ) ;
if ( AI . busy ) return ;
if ( ! settings ( ) . aiModel ) { toast ( T ( 'Please select an Ollama model in the settings first.' ) , 'error' ) ; return ; }
if ( ! AI . chat ) AI . chat = { id : uid ( ) , title : text . replace ( /\s+/g , ' ' ) . slice ( 0 , 60 ) , messages : [ ] } ;
const chat = AI . chat , t = activeSession ( ) ;
chat . messages . push ( { role : 'user' , content : text , tab : t ? . title || '' } ) ;
aiRender ( ) ;
const box = $ ( '.msgs' , p ) ;
$ ( '.hint' , box ) ? . remove ( ) ;
const out = h ( '<div class="m bot"><div class="spinner"></div></div>' ) ; box . append ( out ) ;
box . scrollTop = box . scrollHeight ;
const reply = { role : 'assistant' , content : '' } ;
aiSetBusy ( true ) ;
const finish = async ( err ) => {
AI . unsub ? . ( ) ; AI . unsub = null ; aiSetBusy ( false ) ;
if ( err ) { out . classList . add ( 'err' ) ; out . textContent = err ; chat . messages . pop ( ) ; }
else if ( reply . content ) { chat . messages . push ( reply ) ; await aiSave ( ) ; if ( AI . chat === chat ) aiRender ( ) ; }
else out . remove ( ) ;
} ;
AI . unsub = api . on ( 'ai:event' , ( cid , type , payload ) => {
if ( cid !== chat . id ) return ;
if ( type === 'data' ) {
reply . content += payload ;
if ( AI . chat !== chat ) return ;
const stick = box . scrollHeight - box . scrollTop - box . clientHeight < 40 ;
out . innerHTML = aiMarkdown ( reply . content ) ;
if ( stick ) box . scrollTop = box . scrollHeight ;
} else finish ( type === 'error' ? payload : null ) ;
} ) ;
const ctx = $ ( '[name=ctx]' , p ) . checked ? aiContext ( ) : '' ;
try { await api . call ( 'ai:chat' , chat . id , chat . messages . slice ( - 20 ) . map ( ( { role , content } ) => ( { role , content } ) ) , ctx ) ; }
catch ( e ) { finish ( e . message ) ; }
}
function initAi ( ) {
const p = $ ( '#aiPanel' ) ;
p . innerHTML = ` <div class="head"><span> ${ ICONS . ai } ${ T ( 'Support assistant' ) } </span>
<span><button class="btn ghost sm" data-ai="new" title=" ${ T ( 'New chat' ) } "> ${ ICONS . plus } </button><button class="btn ghost sm" data-ai="del" title=" ${ T ( 'Delete chat' ) } "> ${ ICONS . trash } </button><button class="btn ghost sm" data-ai-toggle title=" ${ T ( 'Close' ) } "> ${ ICONS . close } </button></span></div>
<div class="bar"><select class="chats" title=" ${ T ( 'Saved chats' ) } "></select></div>
<div class="msgs"></div>
<form class="ask"><label class="check" title=" ${ T ( 'The content of the active tab is sent to the Ollama server.' ) } "><input type="checkbox" name="ctx" checked/> ${ T ( 'Context:' ) } <b class="ctx-name"></b></label>
<textarea rows="3" placeholder=" ${ T ( 'Describe the problem … (Enter = send)' ) } "></textarea>
<div class="row"><button type="button" class="btn sm" data-ai="explain"> ${ T ( 'Explain this tab' ) } </button><button class="btn primary sm" type="submit"> ${ T ( 'Send' ) } </button></div></form> ` ;
const ta = $ ( 'textarea' , p ) ;
$ ( 'form' , p ) . onsubmit = ( e ) => {
e . preventDefault ( ) ;
if ( AI . busy ) { api . call ( 'ai:stop' , AI . chat ? . id ) ; return ; }
if ( ta . value . trim ( ) ) { aiAsk ( ta . value . trim ( ) ) ; ta . value = '' ; }
} ;
ta . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Enter' && ! e . shiftKey ) { e . preventDefault ( ) ; if ( ! AI . busy ) $ ( 'form' , p ) . requestSubmit ( ) ; } } ) ;
$ ( '.chats' , p ) . onchange = ( e ) => {
if ( AI . busy ) api . call ( 'ai:stop' , AI . chat ? . id ) ;
AI . chat = aiChats ( ) . find ( ( c ) => c . id === e . target . value ) || null ;
aiRender ( ) ;
} ;
p . addEventListener ( 'click' , async ( e ) => {
const a = e . target . closest ( '[data-ai]' ) ? . dataset . ai ;
if ( a === 'new' ) { if ( AI . busy ) api . call ( 'ai:stop' , AI . chat ? . id ) ; AI . chat = null ; aiRender ( ) ; ta . focus ( ) ; }
if ( a === 'del' && AI . chat && await confirmBox ( T ( 'Delete chat?' ) , ` “ ${ AI . chat . title } ” ` , T ( 'Delete' ) ) ) {
if ( AI . busy ) api . call ( 'ai:stop' , AI . chat . id ) ;
S . vault . aiChats = aiChats ( ) . filter ( ( c ) => c !== AI . chat ) ;
await call ( 'vault:remove' , 'aiChats' , AI . chat . id ) . catch ( ( ) => { } ) ;
AI . chat = null ; aiRender ( ) ;
}
if ( a === 'explain' ) {
const t = activeSession ( ) ;
aiAsk ( t ? . kind === 'ssh' ? T ( 'Explain the last error in the terminal output and how to fix it.' ) : T ( 'Look at the active tab, explain what you see and point out problems.' ) ) ;
}
const b = e . target . closest ( '[data-cmd]' ) ;
if ( ! b ) return ;
const code = b . closest ( '.code' ) . querySelector ( 'code' ) . textContent . replace ( /\n+$/ , '' ) ;
const t = activeSession ( ) ;
// Nur einfügen, nicht ausführen – der Nutzer prüft und bestätigt mit Enter
if ( b . dataset . cmd === 'paste' && t ? . kind === 'ssh' ) { t . term . paste ( code ) ; t . term . focus ( ) ; }
else { api . call ( 'clipboard:write' , code ) ; toast ( b . dataset . cmd === 'paste' ? T ( 'Copied – no terminal tab is active' ) : T ( 'Copied' ) ) ; }
} ) ;
if ( $ ( '#aiPanel' ) . classList . contains ( 'open' ) ) aiRender ( ) ;
if ( AI . init ) return ;
AI . init = true ;
document . addEventListener ( 'click' , ( e ) => { if ( e . target . closest ( '[data-ai-toggle]' ) ) aiToggle ( ) ; } ) ;
}
// ============================================================ RDP-Session (eingebettet im Tab)
// Das RDP-Fenster ist ein natives Kindfenster und liegt immer über dem HTML.
// Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist.
@@ -2183,6 +2281,7 @@ class DockerSession {
<div class="seg sm"><button class="active" data-v="containers"> ${ T ( 'Containers' ) } </button><button data-v="stacks">Stacks</button></div>
<label class="search sm"> ${ ICONS . search } <input placeholder=" ${ esc ( T ( 'Search …' ) ) } "/></label>
<label class="check stopped-opt" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/> ${ T ( 'Show stopped' ) } </label>
<button class="btn ghost sm" data-ai-toggle title=" ${ T ( 'Support assistant (Ollama)' ) } "> ${ ICONS . ai } </button>
<button class="btn ghost sm" data-a="refresh" title=" ${ T ( 'Refresh' ) } "> ${ ICONS . refresh } </button>
</div>
<div class="docker-body"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div> ${ esc ( T ( 'Connecting to {host} …' , { host : host . address } ))}</div></div></div></div> ` ) ;
@@ -2457,6 +2556,12 @@ class DockerSession {
} catch { }
}
aiContext ( ) {
if ( ! this . editing || ! $ ( '.se-compose' , this . body ) ) return this . body . innerText ;
const env = $ ( '.se-env' , this . body ) . value . replace ( /^(\s*[^#=\s]+\s*=).*$/gm , '$1***' ) ;
return ` Editing compose stack " ${ this . editing . name || '' } " \n \n compose.yaml: \n ${ $ ( '.se-compose' , this . body ) . value } \n \n .env (values hidden): \n ${ env } ` ;
}
// Editor für compose.yaml und .env; st.file fehlt bei neuen Stacks
editStack ( st ) {
this . editing = st ;
@@ -2514,6 +2619,7 @@ class FirewallSession {
<div class="sbar"><div class="info"> ${ avatar ( host , 22 ) } <span> ${ esc ( hostSub ( host ) ) } </span><span class="rt"></span></div>
<div class="seg sm backends"></div>
<button class="btn ghost sm" data-a="save" style="display:none" title=" ${ esc ( T ( 'Save the current iptables rules so they survive a reboot' ) ) } "> ${ ICONS . download } ${ T ( 'Save permanently' ) } </button>
<button class="btn ghost sm" data-ai-toggle title=" ${ T ( 'Support assistant (Ollama)' ) } "> ${ ICONS . ai } </button>
<button class="btn ghost sm" data-a="refresh" title=" ${ T ( 'Refresh' ) } "> ${ ICONS . refresh } </button>
<button class="btn primary sm" data-a="add" disabled> ${ ICONS . plus } ${ T ( 'Add rule' ) } </button>
</div>
@@ -2707,6 +2813,7 @@ class NetworkSession {
<div class="sbar"><div class="info"> ${ avatar ( host , 22 ) } <span> ${ esc ( hostSub ( host ) ) } </span><span class="rt"></span></div>
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="virtual"/> ${ T ( 'Show virtual interfaces' ) } </label>
<button class="btn ghost sm" data-a="files"> ${ ICONS . file } ${ T ( 'Config files' ) } </button>
<button class="btn ghost sm" data-ai-toggle title=" ${ T ( 'Support assistant (Ollama)' ) } "> ${ ICONS . ai } </button>
<button class="btn ghost sm" data-a="refresh" title=" ${ T ( 'Refresh' ) } "> ${ ICONS . refresh } </button>
<button class="btn primary sm" data-a="new" disabled> ${ ICONS . plus } ${ T ( 'New interface' ) } </button>
</div>