From 4176cc0a0182311212130464892db0185cf4f0b5 Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 1 Oct 2026 22:44:35 +0200 Subject: [PATCH] Support assistant across all tabs: global panel (title bar button and in terminal, Docker, firewall and network tabs) that uses the active tab as context (terminal output, container/stack view, compose editor with masked .env values, firewall rules, network config); chats are stored in the vault and can be switched, started anew and deleted; stop button while answering Co-Authored-By: Claude Opus 5.5 --- src/i18n.js | 12 ++- src/main/ai.js | 8 +- src/main/store.js | 2 + src/renderer/app.js | 203 ++++++++++++++++++++++++++++++---------- src/renderer/index.html | 2 + src/renderer/styles.css | 11 ++- 6 files changed, 184 insertions(+), 54 deletions(-) diff --git a/src/i18n.js b/src/i18n.js index 5e9310b..9d0ad18 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -152,6 +152,17 @@ 'Command is missing.': 'Befehl fehlt.', 'No open terminal': 'Kein offenes Terminal', // Support-Assistent + 'Delete chat': 'Chat löschen', + 'Delete chat?': 'Chat löschen?', + 'Saved chats': 'Gespeicherte Chats', + 'Context:': 'Kontext:', + 'Vault (no context)': 'Tresor (kein Kontext)', + 'Explain this tab': 'Diesen Tab erklären', + 'Look at the active tab, explain what you see and point out problems.': 'Sieh dir den aktiven Tab an, erkläre, was du siehst, und weise auf Probleme hin.', + 'The content of the active tab is sent to the Ollama server.': 'Der Inhalt des aktiven Tabs wird an den Ollama-Server gesendet.', + 'Ask about an error or let the assistant explain what you see. The content of the active tab is sent as context.': 'Frag nach einem Fehler oder lass dir erklären, was du siehst. Der Inhalt des aktiven Tabs wird als Kontext mitgesendet.', + 'Copied – no terminal tab is active': 'Kopiert – kein Terminal-Tab aktiv', + 'Helps with errors in the Linux console, Docker, firewall and network via a local Ollama 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.': 'Hilft bei Fehlern in Linux-Konsole, Docker, Firewall und Netzwerk über eine lokale Ollama-Instanz. Öffnen über den Chat-Button in der Titelleiste oder im Tab. Der Inhalt des aktiven Tabs wird als Kontext an den Ollama-Server gesendet. Chats werden im Tresor gespeichert.', 'Support assistant (Ollama)': 'Support-Assistent (Ollama)', 'Assistant': 'Assistent', 'Support assistant': 'Support-Assistent', @@ -164,7 +175,6 @@ 'Please select an Ollama model in the settings first.': 'Bitte zuerst in den Einstellungen ein Ollama-Modell auswählen.', 'Insert into terminal (not executed)': 'Ins Terminal einfügen (wird nicht ausgeführt)', 'Insert': 'Einfügen', - 'Helps with errors in the Linux console via a local Ollama instance. Open it in a terminal tab with the Assistant button. The latest terminal output is sent to the Ollama server as context.': 'Hilft bei Fehlern in der Linux-Konsole über eine lokale Ollama-Instanz. Im Terminal-Tab über den Button Assistent öffnen. Die letzte Terminalausgabe wird als Kontext an den Ollama-Server gesendet.', 'Ollama server': 'Ollama-Server', 'Model': 'Modell', '– select –': '– auswählen –', diff --git a/src/main/ai.js b/src/main/ai.js index c978971..1544e5d 100644 --- a/src/main/ai.js +++ b/src/main/ai.js @@ -4,8 +4,10 @@ const http = require('http'); const https = require('https'); const SYSTEM_PROMPT = `You are the MrTerm support assistant, an experienced Linux system administrator. -The user works in an SSH terminal on a Linux server (typically Ubuntu, Debian or Proxmox). -You may receive the latest terminal output as context. Your job: +The user manages Linux servers (typically Ubuntu, Debian or Proxmox) with MrTerm: SSH terminals, Docker containers +and compose stacks, firewall (UFW/iptables), network configuration and SFTP. +You may receive the content of the user's active MrTerm tab as context (terminal output, container list, compose file, +firewall rules, …). Your job: - Explain errors briefly and clearly, name the most likely cause. - Suggest concrete, copy-pasteable shell commands in \`\`\`bash code blocks, one step at a time. - Warn explicitly before destructive commands (rm -rf, dd, mkfs, firewall changes that may lock out SSH, network changes). @@ -56,7 +58,7 @@ class AiAssistant { this.stop(chatId); const ctrl = new AbortController(); this.running.set(chatId, ctrl); - const sys = SYSTEM_PROMPT + (context ? `\n\nLatest terminal output:\n\`\`\`\n${context.slice(-12000)}\n\`\`\`` : ''); + const sys = SYSTEM_PROMPT + (context ? `\n\nContent of the active tab:\n\`\`\`\n${context.slice(-12000)}\n\`\`\`` : ''); (async () => { try { const res = await request(url, 'api/chat', { method: 'POST', signal: ctrl.signal, body: { model, stream: true, messages: [{ role: 'system', content: sys }, ...messages] } }); diff --git a/src/main/store.js b/src/main/store.js index 5d7711b..accaf63 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -16,6 +16,8 @@ const DEFAULTS = { vpns: [], knownHosts: {}, history: [], + // Chats des Support-Assistenten (nur lokal, nicht synchronisiert) + aiChats: [], // Löschvermerke für die Synchronisation: { c: Collection, id, at } tombstones: [], // LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert) diff --git a/src/renderer/app.js b/src/renderer/app.js index f6f6101..396fd79 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -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}${T('Vault encrypted')}` : `${ICONS.unlock}${T('Vault not encrypted')}`; 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(`

${T('Support assistant')}

${T('Helps with errors in the Linux console via a local Ollama instance. Open it in a terminal tab with the Assistant button. The latest terminal output is sent to the Ollama server as context.')}

`); + const aiCard = h(`

${T('Support assistant')}

${T('Helps with errors in the Linux console, Docker, firewall and network via a local Ollama 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.')}

`); 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 { ${host.execCommand ? '' : ``} - +
Snippets
-
${T('Support assistant')}
-
-
- -
`); 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(`
${esc(text)}
`)); - const out = h('
'); 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\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[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(`
${T('Ask about an error or let the assistant explain what you see. The content of the active tab is sent as context.')}
`)); + for (const m of AI.chat?.messages || []) { + if (m.role === 'user') box.append(h(`
${m.tab ? `${esc(m.tab)}` : ''}${esc(m.content)}
`)); + else { const d = h('
'); 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('
'); 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 = `
${ICONS.ai}${T('Support assistant')} +
+
+
+
+ +
`; + 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 {
+
${esc(T('Connecting to {host} …', { host: host.address }))}
`); @@ -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\ncompose.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 {
${avatar(host, 22)}${esc(hostSub(host))}
+
@@ -2707,6 +2813,7 @@ class NetworkSession {
${avatar(host, 22)}${esc(hostSub(host))}
+
diff --git a/src/renderer/index.html b/src/renderer/index.html index c311b4b..048135b 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -19,6 +19,7 @@
+
@@ -52,6 +53,7 @@
+
diff --git a/src/renderer/styles.css b/src/renderer/styles.css index c9d9e63..8d70006 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -198,13 +198,19 @@ body.in-session #sidebar { display: none; } .snip-tpl .it:hover { background: var(--card-hover); } .snip-tpl .hint { color: var(--muted); font-size: 12px; margin-top: 3px; } .snip-panel .it .mono { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; } -/* Support-Assistent */ -.ai-panel { width: 380px; border-left: 1px solid var(--border); background: var(--bg-2); display: none; flex-direction: column; flex: none; min-height: 0; } +/* Support-Assistent (rechts neben allen Tabs) */ +.ai-panel { width: 400px; border-left: 1px solid var(--border); background: var(--bg-2); display: none; flex-direction: column; flex: none; min-height: 0; } .ai-panel.open { display: flex; } .ai-panel .head { padding: 8px 12px; font-weight: 600; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; } +.ai-panel .head > span:first-child { display: flex; align-items: center; gap: 8px; } +.ai-panel .head svg { width: 16px; height: 16px; } +.ai-panel .bar { padding: 8px 12px; border-bottom: 1px solid var(--border); } +.ai-panel .bar select { width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; color: inherit; } .ai-panel .msgs { flex: 1; overflow: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; user-select: text; } +.ai-panel .msgs .hint { color: var(--muted); padding: 8px; } .ai-panel .m { border-radius: 8px; padding: 8px 10px; line-height: 1.45; overflow-wrap: anywhere; } .ai-panel .m.user { background: var(--accent); color: #fff; align-self: flex-end; max-width: 90%; white-space: pre-wrap; } +.ai-panel .m.user small { display: block; opacity: .75; font-size: 10px; margin-bottom: 2px; } .ai-panel .m.bot { background: var(--card); } .ai-panel .m.err { color: var(--red, #ff6b6b); } .ai-panel .m code { font-family: var(--mono, monospace); font-size: 12px; background: var(--bg); padding: 1px 4px; border-radius: 4px; } @@ -213,6 +219,7 @@ body.in-session #sidebar { display: none; } .ai-panel .code pre { margin: 0; padding: 8px; overflow: auto; } .ai-panel .code pre code { background: none; padding: 0; } .ai-panel .ask { border-top: 1px solid var(--border); padding: 8px; display: flex; flex-direction: column; gap: 6px; } +.ai-panel .ask .check { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; } .ai-panel .ask textarea { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; resize: vertical; color: inherit; font: inherit; outline: none; } .ai-panel .ask .row { justify-content: space-between; } .ai-panel .spinner { width: 16px; height: 16px; }