Files
MrTerm/src/main/ai.js
T

94 lines
4.2 KiB
JavaScript

// 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 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).
- 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\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] } });
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 };