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 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 21:41:56 +02:00
co-authored by Claude Opus 5.5
parent a279ef88b2
commit 8c8dc0d4c0
7 changed files with 233 additions and 3 deletions
+88 -3
View File
@@ -46,6 +46,7 @@ const ICONS = {
docker: '<svg viewBox="0 0 24 24"><path d="M2 12h19c-.6 4.5-4 8-10 8-5 0-8-3-9-8z"/><path d="M5 12V9h3v3M8 12V9h3v3M11 12V9h3v3M8 9V6h3v3M21 12c.5-1.5 0-3-1-3.5"/></svg>',
wall: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M3 9.3h18M3 14.7h18M9 4v5.3M15 4v5.3M6 9.3v5.4M12 9.3v5.4M18 9.3v5.4M9 14.7V20M15 14.7V20"/></svg>',
network: '<svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1"/><rect x="3" y="16" width="6" height="5" rx="1"/><rect x="15" y="16" width="6" height="5" rx="1"/><path d="M12 8v4M6 16v-4h12v4"/></svg>',
ai: '<svg viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/><path d="M9 10h.01M12 10h.01M15 10h.01"/></svg>',
logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>',
};
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(`<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 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(`<button class="btn">${ICONS.refresh}${T('Load models')}</button>`);
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(`<div class="settings-card"><h3>RDP</h3><div class="hint rdp-info" style="color:var(--muted);margin-bottom:12px">${T('Checking …')}</div></div>`);
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) {
<span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span>
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span>
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
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 {
<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-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();
@@ -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(`<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');
@@ -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 `<div class="code"><div class="acts"><button class="btn ghost sm" data-cmd="copy">${T('Copy')}</button><button class="btn ghost sm" data-cmd="paste" title="${T('Insert into terminal (not executed)')}">${T('Insert')}</button></div><pre><code>${esc(code)}</code></pre></div>`;
}
return esc(part).replace(/`([^`\n]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/\n/g, '<br>');
}).join('');
}
function openTerminal(host) { return new TerminalSession(host); }
+18
View File
@@ -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; }