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('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.')}
${esc(code)}$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\n/g, '