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:
+88
-3
@@ -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); }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user