From 8c8dc0d4c054dc01d54a1b3f461240983627b18d Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 1 Oct 2026 21:38:43 +0200 Subject: [PATCH] Add support assistant via Ollama: chat panel in terminal tabs with the latest terminal output as context, 'explain last error', copy/insert suggested commands (never auto-executed), Ollama server/model in settings Co-Authored-By: Claude Opus 5.5 --- src/core/backend.js | 8 ++++ src/i18n.js | 19 +++++++++ src/main/ai.js | 91 +++++++++++++++++++++++++++++++++++++++++ src/main/main.js | 7 ++++ src/main/store.js | 2 + src/renderer/app.js | 91 +++++++++++++++++++++++++++++++++++++++-- src/renderer/styles.css | 18 ++++++++ 7 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 src/main/ai.js diff --git a/src/core/backend.js b/src/core/backend.js index 343bcd3..3b4ac92 100644 --- a/src/core/backend.js +++ b/src/core/backend.js @@ -11,6 +11,7 @@ const { SshManager } = require('../main/ssh'); const { DockerManager } = require('../main/docker'); const { FirewallManager } = require('../main/firewall'); const { NetworkConfigManager } = require('../main/network'); +const { AiAssistant } = require('../main/ai'); const i18n = require('../i18n'); const backup = require('./backup'); const composeTemplates = require('../main/compose-templates'); @@ -34,6 +35,7 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa const docker = new DockerManager(ssh); const firewall = new FirewallManager(ssh); const network = new NetworkConfigManager(ssh); + const ai = new AiAssistant(store, send); let sync = null; if (withSync) { const { SyncService } = require('../main/sync'); @@ -206,6 +208,11 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify)); handle('network:close', (id) => network.close(id)); + // ---------- Support-Assistent (Ollama) ---------- + handle('ai:models', (url) => ai.models(url)); + handle('ai:chat', (id, messages, context) => ai.chat(id, messages, context)); + handle('ai:stop', (id) => ai.stop(id)); + // ---------- Synchronisation ---------- if (sync) { handle('sync:status', () => sync.status()); @@ -221,6 +228,7 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa function close() { ssh.closeAll?.(); + ai.stopAll(); sync?.stop(); for (const r of pending.values()) r(null); pending.clear(); diff --git a/src/i18n.js b/src/i18n.js index e2014af..5e9310b 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -151,6 +151,25 @@ 'Send Enter automatically': 'Enter automatisch senden', 'Command is missing.': 'Befehl fehlt.', 'No open terminal': 'Kein offenes Terminal', + // Support-Assistent + 'Support assistant (Ollama)': 'Support-Assistent (Ollama)', + 'Assistant': 'Assistent', + 'Support assistant': 'Support-Assistent', + 'New chat': 'Neuer Chat', + 'Include terminal output': 'Terminalausgabe mitsenden', + 'Describe the problem … (Enter = send)': 'Problem beschreiben … (Enter = senden)', + 'Explain last error': 'Letzten Fehler erklären', + 'Send': 'Senden', + 'Explain the last error in the terminal output and how to fix it.': 'Erkläre den letzten Fehler in der Terminalausgabe und wie ich ihn behebe.', + '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 –', + 'No models installed (ollama pull …)': 'Keine Modelle installiert (ollama pull …)', + 'Load models': 'Modelle laden', 'No snippets yet.': 'Noch keine Snippets.', // Port Forwarding diff --git a/src/main/ai.js b/src/main/ai.js new file mode 100644 index 0000000..c978971 --- /dev/null +++ b/src/main/ai.js @@ -0,0 +1,91 @@ +// Support-Assistent über eine Ollama-Instanz (lokal oder im Netz). +// Antworten werden gestreamt: send('ai:event', chatId, 'data' | 'done' | 'error', payload) +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: +- 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). +- If information is missing, say which command would provide it. +Answer in the language of the user's question. Be concise.`; + +function request(base, path, { method = 'GET', body, signal } = {}) { + const url = new URL(path, base.endsWith('/') ? base : base + '/'); + const lib = url.protocol === 'https:' ? https : http; + return new Promise((resolve, reject) => { + const req = lib.request(url, { method, headers: body ? { 'Content-Type': 'application/json' } : {}, signal, timeout: 15000 }, (res) => { + if (res.statusCode >= 400) { + let t = ''; res.on('data', (c) => (t += c)); + res.on('end', () => { let m = t; try { m = JSON.parse(t).error || t; } catch {} reject(new Error(`Ollama: ${m || res.statusCode}`)); }); + return; + } + resolve(res); + }); + req.on('timeout', () => req.destroy(new Error('Ollama: timeout'))); + req.on('error', (e) => reject(e.code === 'ECONNREFUSED' ? new Error(`Ollama not reachable at ${base}`) : e)); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +class AiAssistant { + constructor(store, send) { + this.store = store; + this.send = send; + this.running = new Map(); + } + + get cfg() { + const s = this.store.get().settings; + return { url: s.aiUrl || 'http://localhost:11434', model: s.aiModel || '' }; + } + + async models(url) { + const res = await request(url || this.cfg.url, 'api/tags'); + let t = ''; for await (const c of res) t += c; + return (JSON.parse(t).models || []).map((m) => m.name); + } + + // messages: [{ role: 'user' | 'assistant', content }], context: letzte Terminalausgabe + async chat(chatId, messages, context = '') { + const { url, model } = this.cfg; + if (!model) throw new Error('No Ollama model selected (Settings → Support assistant).'); + 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\`\`\`` : ''); + (async () => { + try { + const res = await request(url, 'api/chat', { method: 'POST', signal: ctrl.signal, body: { model, stream: true, messages: [{ role: 'system', content: sys }, ...messages] } }); + res.setTimeout(0); + let buf = ''; + for await (const chunk of res) { + buf += chunk; + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1); + if (!line) continue; + const m = JSON.parse(line); + if (m.error) throw new Error(`Ollama: ${m.error}`); + if (m.message?.content) this.send('ai:event', chatId, 'data', m.message.content); + } + } + this.send('ai:event', chatId, 'done'); + } catch (e) { + if (ctrl.signal.aborted) this.send('ai:event', chatId, 'done'); + else this.send('ai:event', chatId, 'error', e.message || String(e)); + } finally { + if (this.running.get(chatId) === ctrl) this.running.delete(chatId); + } + })(); + return true; + } + + stop(chatId) { this.running.get(chatId)?.abort(); this.running.delete(chatId); } + stopAll() { for (const c of this.running.values()) c.abort(); this.running.clear(); } +} + +module.exports = { AiAssistant }; diff --git a/src/main/main.js b/src/main/main.js index 6b260b9..c6516c5 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -18,6 +18,7 @@ const { NetworkConfigManager } = require('./network'); const { SyncService } = require('./sync'); const backup = require('../core/backup'); const composeTemplates = require('./compose-templates'); +const { AiAssistant } = require('./ai'); const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale()); let win; @@ -77,6 +78,7 @@ const vpn = new VpnManager(store, app.getPath('userData')); const docker = new DockerManager(ssh); const firewall = new FirewallManager(ssh); const network = new NetworkConfigManager(ssh); +const ai = new AiAssistant(store, send); // LAN-Synchronisation; Vergleichscode beim Koppeln bestätigt der Nutzer in der Oberfläche const pairReplies = new Map(); @@ -278,6 +280,11 @@ handle('sync:share', (sel) => sync.setShare(sel)); handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes)); handle('sync:probe', (address) => sync.probe(address)); +// ---------- Support-Assistent (Ollama) ---------- +handle('ai:models', (url) => ai.models(url)); +handle('ai:chat', (id, messages, context) => ai.chat(id, messages, context)); +handle('ai:stop', (id) => ai.stop(id)); + // ---------- VPN ---------- handle('vpn:status', () => vpn.status()); handle('vpn:up', (id) => vpn.up(id)); diff --git a/src/main/store.js b/src/main/store.js index cc69126..5d7711b 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -41,6 +41,8 @@ const DEFAULTS = { language: 'auto', dockerStacksDir: '/opt/stacks', dockerTemplatesUrl: '', + aiUrl: 'http://localhost:11434', + aiModel: '', }, }; diff --git a/src/renderer/app.js b/src/renderer/app.js index 015a0f0..f6f6101 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -46,6 +46,7 @@ const ICONS = { docker: '', wall: '', network: '', + ai: '', logs: '', }; const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100']; @@ -1051,6 +1052,28 @@ async function viewSettings(page) { save({ ...v, fontSize: Number(v.fontSize) || 14, scrollback: Number(v.scrollback) || 10000, keepAlive: Number(v.keepAlive) || 0 }); }); + // ---- 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 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 () => { + const sel = $('select', aiModel); + try { + const list = await api.call('ai:models', $('input', aiUrl).value.trim()); + sel.innerHTML = ''; + if (!list.length) sel.append(new Option(T('No models installed (ollama pull …)'), '')); + list.forEach((m) => sel.append(new Option(m, m))); + sel.value = list.includes(st.aiModel) ? st.aiModel : list[0] || ''; + if (sel.value !== (settings().aiModel || '')) save({ aiModel: sel.value }); + } catch (e) { toast(e.message, 'error'); } + }; + const aiReload = h(``); + aiReload.onclick = loadModels; + $('input', aiUrl).onchange = () => save({ aiUrl: $('input', aiUrl).value.trim() }).then(loadModels); + $('select', aiModel).onchange = () => save({ aiModel: $('select', aiModel).value }); + aiCard.append(aiUrl, aiModel, aiReload); + if (st.aiModel) loadModels(); + const rdpCard = h(`

RDP

${T('Checking …')}
`); const emb = check(T('Show RDP connections as tabs in MrTerm'), 'rdpEmbed', st.rdpEmbed !== false); emb.onchange = async () => { @@ -1132,8 +1155,8 @@ async function viewSettings(page) { ${C}+Shift+F${T('Search in terminal')} ${C}++/−/0${T('Font size')} ${C}+Shift+L${T('Lock now')}`); - if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, dockerCard, importCard, dataCard, keysCard); - else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, dockerCard, importCard, dataCard, updCard, keysCard); + if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, aiCard, dockerCard, importCard, dataCard, keysCard); + else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, aiCard, rdpCard, dockerCard, importCard, dataCard, updCard, keysCard); } // ============================================================ App-Sperre @@ -1569,13 +1592,20 @@ class TerminalSession { ${host.execCommand ? '' : ``} +
Snippets
+
${T('Support assistant')}
+
+
+ +
`); this.overlay = null; + this.chat = []; this.alive = false; this.initTerm(); this.bindUi(); @@ -1630,6 +1660,9 @@ 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); @@ -1637,6 +1670,16 @@ 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); @@ -1655,6 +1698,38 @@ class TerminalSession { box.append(templateList(this)); } + // Letzte Zeilen des Terminals als Kontext für den Assistenten + termContext(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'); @@ -1727,7 +1802,17 @@ class TerminalSession { this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`); } } - dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); } + dispose() { this.unsub(); this.aiUnsub?.(); api.call('ai:stop', this.id).catch(() => {}); 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) { + return md.split(/```/).map((part, i) => { + if (i % 2) { + const code = part.replace(/^[\w-]*\n/, ''); + return `
${esc(code)}
`; + } + return esc(part).replace(/`([^`\n]+)`/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\n/g, '
'); + }).join(''); } function openTerminal(host) { return new TerminalSession(host); } diff --git a/src/renderer/styles.css b/src/renderer/styles.css index c5ff1e2..c9d9e63 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -198,6 +198,24 @@ 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; } +.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 .msgs { flex: 1; overflow: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; user-select: text; } +.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.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; } +.ai-panel .code { margin: 6px 0; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; } +.ai-panel .code .acts { display: flex; justify-content: flex-end; gap: 2px; padding: 2px; border-bottom: 1px solid var(--border); } +.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 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; } .findbar { position: absolute; top: 8px; right: 24px; z-index: 4; display: none; gap: 4px; align-items: center; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px #0008; } .findbar.open { display: flex; } .findbar input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; outline: none; width: 200px; }