Compare commits

..
3 Commits
9 changed files with 207 additions and 57 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mrterm", "name": "mrterm",
"version": "0.17.1", "version": "0.18.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mrterm", "name": "mrterm",
"version": "0.17.1", "version": "0.18.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mrterm", "name": "mrterm",
"productName": "MrTerm", "productName": "MrTerm",
"version": "0.17.1", "version": "0.18.1",
"description": "Moderner SSH-, SFTP- und RDP-Client", "description": "Moderner SSH-, SFTP- und RDP-Client",
"main": "src/main/main.js", "main": "src/main/main.js",
"author": "MrBlake", "author": "MrBlake",
@@ -53,6 +53,7 @@
"directories": { "directories": {
"buildResources": "build" "buildResources": "build"
}, },
"beforePack": "scripts/ensure-koffi.js",
"asarUnpack": [ "asarUnpack": [
"node_modules/koffi/**", "node_modules/koffi/**",
"node_modules/@koromix/**" "node_modules/@koromix/**"
+19
View File
@@ -0,0 +1,19 @@
// electron-builder beforePack: stellt sicher, dass die native koffi-Binary der Zielplattform in node_modules liegt.
// npm installiert nur die Binary der Build-Plattform – beim Cross-Build (z. B. Windows-Paket auf Linux) fehlt sie sonst,
// und RDP im Tab fällt mit "koffi nicht verfügbar" auf ein externes Fenster zurück.
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { Arch } = require('electron-builder');
module.exports = async (context) => {
const platform = context.electronPlatformName;
const arch = Arch[context.arch];
const pkg = `@koromix/koffi-${platform}-${arch}`;
const root = path.join(__dirname, '..');
if (fs.existsSync(path.join(root, 'node_modules', pkg))) return;
const { version } = require(path.join(root, 'node_modules', 'koffi', 'package.json'));
console.log(` • koffi-Binary für ${platform}-${arch} fehlt – installiere ${pkg}@${version}`);
execSync(`npm install --no-save --force ${pkg}@${version}`, { cwd: root, stdio: 'inherit' });
if (!fs.existsSync(path.join(root, 'node_modules', pkg))) throw new Error(`${pkg} konnte nicht installiert werden`);
};
+11 -1
View File
@@ -152,6 +152,17 @@
'Command is missing.': 'Befehl fehlt.', 'Command is missing.': 'Befehl fehlt.',
'No open terminal': 'Kein offenes Terminal', 'No open terminal': 'Kein offenes Terminal',
// Support-Assistent // Support-Assistent
'Delete chat': 'Chat löschen',
'Delete chat?': 'Chat löschen?',
'Saved chats': 'Gespeicherte Chats',
'Context:': 'Kontext:',
'Vault (no context)': 'Tresor (kein Kontext)',
'Explain this tab': 'Diesen Tab erklären',
'Look at the active tab, explain what you see and point out problems.': 'Sieh dir den aktiven Tab an, erkläre, was du siehst, und weise auf Probleme hin.',
'The content of the active tab is sent to the Ollama server.': 'Der Inhalt des aktiven Tabs wird an den Ollama-Server gesendet.',
'Ask about an error or let the assistant explain what you see. The content of the active tab is sent as context.': 'Frag nach einem Fehler oder lass dir erklären, was du siehst. Der Inhalt des aktiven Tabs wird als Kontext mitgesendet.',
'Copied – no terminal tab is active': 'Kopiert – kein Terminal-Tab aktiv',
'Helps with errors in the Linux console, Docker, firewall and network via a local <b>Ollama</b> instance. Open it with the chat button in the title bar or in a tab. The content of the active tab is sent to the Ollama server as context. Chats are stored in the vault.': 'Hilft bei Fehlern in Linux-Konsole, Docker, Firewall und Netzwerk über eine lokale <b>Ollama</b>-Instanz. Öffnen über den Chat-Button in der Titelleiste oder im Tab. Der Inhalt des aktiven Tabs wird als Kontext an den Ollama-Server gesendet. Chats werden im Tresor gespeichert.',
'Support assistant (Ollama)': 'Support-Assistent (Ollama)', 'Support assistant (Ollama)': 'Support-Assistent (Ollama)',
'Assistant': 'Assistent', 'Assistant': 'Assistent',
'Support assistant': 'Support-Assistent', 'Support assistant': 'Support-Assistent',
@@ -164,7 +175,6 @@
'Please select an Ollama model in the settings first.': 'Bitte zuerst in den Einstellungen ein Ollama-Modell auswählen.', '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 into terminal (not executed)': 'Ins Terminal einfügen (wird nicht ausgeführt)',
'Insert': 'Einfügen', 'Insert': 'Einfügen',
'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.': 'Hilft bei Fehlern in der Linux-Konsole über eine lokale <b>Ollama</b>-Instanz. Im Terminal-Tab über den Button <i>Assistent</i> öffnen. Die letzte Terminalausgabe wird als Kontext an den Ollama-Server gesendet.',
'Ollama server': 'Ollama-Server', 'Ollama server': 'Ollama-Server',
'Model': 'Modell', 'Model': 'Modell',
'– select –': '– auswählen –', '– select –': '– auswählen –',
+5 -3
View File
@@ -4,8 +4,10 @@ const http = require('http');
const https = require('https'); const https = require('https');
const SYSTEM_PROMPT = `You are the MrTerm support assistant, an experienced Linux system administrator. 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). The user manages Linux servers (typically Ubuntu, Debian or Proxmox) with MrTerm: SSH terminals, Docker containers
You may receive the latest terminal output as context. Your job: 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. - 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. - 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). - Warn explicitly before destructive commands (rm -rf, dd, mkfs, firewall changes that may lock out SSH, network changes).
@@ -56,7 +58,7 @@ class AiAssistant {
this.stop(chatId); this.stop(chatId);
const ctrl = new AbortController(); const ctrl = new AbortController();
this.running.set(chatId, ctrl); this.running.set(chatId, ctrl);
const sys = SYSTEM_PROMPT + (context ? `\n\nLatest terminal output:\n\`\`\`\n${context.slice(-12000)}\n\`\`\`` : ''); const sys = SYSTEM_PROMPT + (context ? `\n\nContent of the active tab:\n\`\`\`\n${context.slice(-12000)}\n\`\`\`` : '');
(async () => { (async () => {
try { try {
const res = await request(url, 'api/chat', { method: 'POST', signal: ctrl.signal, body: { model, stream: true, messages: [{ role: 'system', content: sys }, ...messages] } }); const res = await request(url, 'api/chat', { method: 'POST', signal: ctrl.signal, body: { model, stream: true, messages: [{ role: 'system', content: sys }, ...messages] } });
+2
View File
@@ -16,6 +16,8 @@ const DEFAULTS = {
vpns: [], vpns: [],
knownHosts: {}, knownHosts: {},
history: [], history: [],
// Chats des Support-Assistenten (nur lokal, nicht synchronisiert)
aiChats: [],
// Löschvermerke für die Synchronisation: { c: Collection, id, at } // Löschvermerke für die Synchronisation: { c: Collection, id, at }
tombstones: [], tombstones: [],
// LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert) // LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert)
+155 -48
View File
@@ -151,6 +151,7 @@ async function reload() {
S.vault = await call('vault:get'); S.vault = await call('vault:get');
I18N.setLanguage(settings().language, navigator.language); I18N.setLanguage(settings().language, navigator.language);
applyStatic(); applyStatic();
initAi();
const b = $('#vaultBadge'); const b = $('#vaultBadge');
b.innerHTML = S.vault.encrypted ? `${ICONS.lock}<span>${T('Vault encrypted')}</span>` : `${ICONS.unlock}<span>${T('Vault not encrypted')}</span>`; b.innerHTML = S.vault.encrypted ? `${ICONS.lock}<span>${T('Vault encrypted')}</span>` : `${ICONS.unlock}<span>${T('Vault not encrypted')}</span>`;
b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).'); b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).');
@@ -1053,7 +1054,7 @@ async function viewSettings(page) {
}); });
// ---- Support-Assistent (Ollama) // ---- 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 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, Docker, firewall and network via a local <b>Ollama</b> instance. Open it with the chat button in the title bar or in a tab. The content of the active tab is sent to the Ollama server as context. Chats are stored in the vault.')}</p></div>`);
const aiUrl = field(T('Ollama server'), 'aiUrl', st.aiUrl || 'http://localhost:11434', { placeholder: 'http://localhost:11434' }); 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 aiModel = field(T('Model'), 'aiModel', st.aiModel, { type: 'select', options: [[st.aiModel || '', st.aiModel || T('– select –')]] });
const loadModels = async () => { const loadModels = async () => {
@@ -1568,6 +1569,7 @@ function activateTab(id) {
}); });
$('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); $('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
renderTree(); renderTree();
if ($('#aiPanel').classList.contains('open')) aiUpdateCtx();
} }
function closeTab(id) { function closeTab(id) {
const i = S.tabs.findIndex((t) => t.id === id); const i = S.tabs.findIndex((t) => t.id === id);
@@ -1592,20 +1594,14 @@ class TerminalSession {
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button> <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>`} ${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="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-ai-toggle 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> <button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
</div> </div>
<div class="term-wrap"><div class="term"></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="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="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>`); </div></div>`);
this.overlay = null; this.overlay = null;
this.chat = [];
this.alive = false; this.alive = false;
this.initTerm(); this.initTerm();
this.bindUi(); this.bindUi();
@@ -1660,9 +1656,6 @@ class TerminalSession {
if (a === 'network') openNetwork(this.host); if (a === 'network') openNetwork(this.host);
if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); } if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); }
if (a === 'snipnew') editSnippet(); 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(); if (a === 'find') this.toggleFind();
const f = e.target.closest('[data-f]')?.dataset.f; const f = e.target.closest('[data-f]')?.dataset.f;
const inp = $('.findbar input', this.el); const inp = $('.findbar input', this.el);
@@ -1670,16 +1663,6 @@ class TerminalSession {
if (f === 'prev') this.search.findPrevious(inp.value); if (f === 'prev') this.search.findPrevious(inp.value);
if (f === 'close') this.toggleFind(false); 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) => { $('.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 === 'Enter') (e.shiftKey ? this.search.findPrevious : this.search.findNext).call(this.search, e.target.value);
if (e.key === 'Escape') this.toggleFind(false); if (e.key === 'Escape') this.toggleFind(false);
@@ -1699,37 +1682,12 @@ class TerminalSession {
} }
// Letzte Zeilen des Terminals als Kontext für den Assistenten // Letzte Zeilen des Terminals als Kontext für den Assistenten
termContext(lines = 120) { aiContext(lines = 120) {
const b = this.term.buffer.active, out = []; 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) ?? ''); 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(); 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) { toggleFind(open) {
const fb = $('.findbar', this.el); const fb = $('.findbar', this.el);
const o = open ?? !fb.classList.contains('open'); const o = open ?? !fb.classList.contains('open');
@@ -1802,7 +1760,7 @@ class TerminalSession {
this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`); this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`);
} }
} }
dispose() { this.unsub(); this.aiUnsub?.(); api.call('ai:stop', this.id).catch(() => {}); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); } dispose() { this.unsub(); 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 // Minimales Markdown für Assistenten-Antworten: Codeblöcke (mit Kopieren/Einfügen), Inline-Code, Fett
function aiMarkdown(md) { function aiMarkdown(md) {
@@ -1816,6 +1774,146 @@ function aiMarkdown(md) {
} }
function openTerminal(host) { return new TerminalSession(host); } function openTerminal(host) { return new TerminalSession(host); }
// ============================================================ Support-Assistent (tabübergreifend, Chats im Tresor)
const AI = { chat: null, unsub: null, busy: false };
const aiChats = () => (S.vault.aiChats ||= []);
const activeSession = () => S.tabs.find((t) => t.id === S.active);
function aiToggle(open) {
const p = $('#aiPanel'), o = open ?? !p.classList.contains('open');
p.classList.toggle('open', o);
if (o) { aiRender(); $('textarea', p).focus(); }
}
// Kontext aus dem aktiven Tab: Terminalausgabe, Docker-/Firewall-/Netzwerkansicht …
function aiContext() {
const t = activeSession();
if (!t) return '';
const host = t.host ? `, host ${t.host.username ? t.host.username + '@' : ''}${t.host.address}` : '';
const body = String((t.aiContext ? t.aiContext() : t.el.innerText) || '').trim();
return body ? `Active MrTerm tab: ${t.title} (${t.kind}${host})\n\n${body.slice(-12000)}` : '';
}
function aiUpdateCtx() {
const t = activeSession();
$('#aiPanel .ctx-name').textContent = t ? t.title : T('Vault (no context)');
}
function aiRender() {
const p = $('#aiPanel'), sel = $('.chats', p), box = $('.msgs', p);
sel.innerHTML = '';
sel.append(new Option(T('New chat'), ''));
[...aiChats()].sort((a, b) => b.updatedAt - a.updatedAt).forEach((c) => sel.append(new Option(c.title, c.id)));
sel.value = AI.chat?.id && aiChats().includes(AI.chat) ? AI.chat.id : '';
box.innerHTML = '';
if (!AI.chat?.messages.length) box.append(h(`<div class="hint">${T('Ask about an error or let the assistant explain what you see. The content of the active tab is sent as context.')}</div>`));
for (const m of AI.chat?.messages || []) {
if (m.role === 'user') box.append(h(`<div class="m user">${m.tab ? `<small>${esc(m.tab)}</small>` : ''}${esc(m.content)}</div>`));
else { const d = h('<div class="m bot"></div>'); d.innerHTML = aiMarkdown(m.content); box.append(d); }
}
$('[data-ai=del]', p).disabled = !AI.chat?.id || !aiChats().includes(AI.chat);
aiUpdateCtx();
box.scrollTop = box.scrollHeight;
}
async function aiSave() {
const c = AI.chat;
if (!c) return;
if (!aiChats().includes(c)) aiChats().push(c);
c.updatedAt = Date.now();
try { await call('vault:upsert', 'aiChats', c); } catch {}
}
function aiSetBusy(b) {
AI.busy = b;
const btn = $('#aiPanel [type=submit]');
btn.textContent = b ? T('Stop') : T('Send');
btn.classList.toggle('danger', b);
}
async function aiAsk(text) {
const p = $('#aiPanel');
if (!p.classList.contains('open')) aiToggle(true);
if (AI.busy) return;
if (!settings().aiModel) { toast(T('Please select an Ollama model in the settings first.'), 'error'); return; }
if (!AI.chat) AI.chat = { id: uid(), title: text.replace(/\s+/g, ' ').slice(0, 60), messages: [] };
const chat = AI.chat, t = activeSession();
chat.messages.push({ role: 'user', content: text, tab: t?.title || '' });
aiRender();
const box = $('.msgs', p);
$('.hint', box)?.remove();
const out = h('<div class="m bot"><div class="spinner"></div></div>'); box.append(out);
box.scrollTop = box.scrollHeight;
const reply = { role: 'assistant', content: '' };
aiSetBusy(true);
const finish = async (err) => {
AI.unsub?.(); AI.unsub = null; aiSetBusy(false);
if (err) { out.classList.add('err'); out.textContent = err; chat.messages.pop(); }
else if (reply.content) { chat.messages.push(reply); await aiSave(); if (AI.chat === chat) aiRender(); }
else out.remove();
};
AI.unsub = api.on('ai:event', (cid, type, payload) => {
if (cid !== chat.id) return;
if (type === 'data') {
reply.content += payload;
if (AI.chat !== chat) return;
const stick = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
out.innerHTML = aiMarkdown(reply.content);
if (stick) box.scrollTop = box.scrollHeight;
} else finish(type === 'error' ? payload : null);
});
const ctx = $('[name=ctx]', p).checked ? aiContext() : '';
try { await api.call('ai:chat', chat.id, chat.messages.slice(-20).map(({ role, content }) => ({ role, content })), ctx); }
catch (e) { finish(e.message); }
}
function initAi() {
const p = $('#aiPanel');
p.innerHTML = `<div class="head"><span>${ICONS.ai}${T('Support assistant')}</span>
<span><button class="btn ghost sm" data-ai="new" title="${T('New chat')}">${ICONS.plus}</button><button class="btn ghost sm" data-ai="del" title="${T('Delete chat')}">${ICONS.trash}</button><button class="btn ghost sm" data-ai-toggle title="${T('Close')}">${ICONS.close}</button></span></div>
<div class="bar"><select class="chats" title="${T('Saved chats')}"></select></div>
<div class="msgs"></div>
<form class="ask"><label class="check" title="${T('The content of the active tab is sent to the Ollama server.')}"><input type="checkbox" name="ctx" checked/>${T('Context:')}&nbsp;<b class="ctx-name"></b></label>
<textarea rows="3" placeholder="${T('Describe the problem … (Enter = send)')}"></textarea>
<div class="row"><button type="button" class="btn sm" data-ai="explain">${T('Explain this tab')}</button><button class="btn primary sm" type="submit">${T('Send')}</button></div></form>`;
const ta = $('textarea', p);
$('form', p).onsubmit = (e) => {
e.preventDefault();
if (AI.busy) { api.call('ai:stop', AI.chat?.id); return; }
if (ta.value.trim()) { aiAsk(ta.value.trim()); ta.value = ''; }
};
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!AI.busy) $('form', p).requestSubmit(); } });
$('.chats', p).onchange = (e) => {
if (AI.busy) api.call('ai:stop', AI.chat?.id);
AI.chat = aiChats().find((c) => c.id === e.target.value) || null;
aiRender();
};
p.addEventListener('click', async (e) => {
const a = e.target.closest('[data-ai]')?.dataset.ai;
if (a === 'new') { if (AI.busy) api.call('ai:stop', AI.chat?.id); AI.chat = null; aiRender(); ta.focus(); }
if (a === 'del' && AI.chat && await confirmBox(T('Delete chat?'), `“${AI.chat.title}”`, T('Delete'))) {
if (AI.busy) api.call('ai:stop', AI.chat.id);
S.vault.aiChats = aiChats().filter((c) => c !== AI.chat);
await call('vault:remove', 'aiChats', AI.chat.id).catch(() => {});
AI.chat = null; aiRender();
}
if (a === 'explain') {
const t = activeSession();
aiAsk(t?.kind === 'ssh' ? T('Explain the last error in the terminal output and how to fix it.') : T('Look at the active tab, explain what you see and point out problems.'));
}
const b = e.target.closest('[data-cmd]');
if (!b) return;
const code = b.closest('.code').querySelector('code').textContent.replace(/\n+$/, '');
const t = activeSession();
// Nur einfügen, nicht ausführen – der Nutzer prüft und bestätigt mit Enter
if (b.dataset.cmd === 'paste' && t?.kind === 'ssh') { t.term.paste(code); t.term.focus(); }
else { api.call('clipboard:write', code); toast(b.dataset.cmd === 'paste' ? T('Copied – no terminal tab is active') : T('Copied')); }
});
if ($('#aiPanel').classList.contains('open')) aiRender();
if (AI.init) return;
AI.init = true;
document.addEventListener('click', (e) => { if (e.target.closest('[data-ai-toggle]')) aiToggle(); });
}
// ============================================================ RDP-Session (eingebettet im Tab) // ============================================================ RDP-Session (eingebettet im Tab)
// Das RDP-Fenster ist ein natives Kindfenster und liegt immer über dem HTML. // Das RDP-Fenster ist ein natives Kindfenster und liegt immer über dem HTML.
// Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist. // Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist.
@@ -2183,6 +2281,7 @@ class DockerSession {
<div class="seg sm"><button class="active" data-v="containers">${T('Containers')}</button><button data-v="stacks">Stacks</button></div> <div class="seg sm"><button class="active" data-v="containers">${T('Containers')}</button><button data-v="stacks">Stacks</button></div>
<label class="search sm">${ICONS.search}<input placeholder="${esc(T('Search …'))}"/></label> <label class="search sm">${ICONS.search}<input placeholder="${esc(T('Search …'))}"/></label>
<label class="check stopped-opt" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/>${T('Show stopped')}</label> <label class="check stopped-opt" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/>${T('Show stopped')}</label>
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}</button>
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button> <button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
</div> </div>
<div class="docker-body"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`); <div class="docker-body"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`);
@@ -2457,6 +2556,12 @@ class DockerSession {
} catch {} } catch {}
} }
aiContext() {
if (!this.editing || !$('.se-compose', this.body)) return this.body.innerText;
const env = $('.se-env', this.body).value.replace(/^(\s*[^#=\s]+\s*=).*$/gm, '$1***');
return `Editing compose stack "${this.editing.name || ''}"\n\ncompose.yaml:\n${$('.se-compose', this.body).value}\n\n.env (values hidden):\n${env}`;
}
// Editor für compose.yaml und .env; st.file fehlt bei neuen Stacks // Editor für compose.yaml und .env; st.file fehlt bei neuen Stacks
editStack(st) { editStack(st) {
this.editing = st; this.editing = st;
@@ -2514,6 +2619,7 @@ class FirewallSession {
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div> <div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
<div class="seg sm backends"></div> <div class="seg sm backends"></div>
<button class="btn ghost sm" data-a="save" style="display:none" title="${esc(T('Save the current iptables rules so they survive a reboot'))}">${ICONS.download}${T('Save permanently')}</button> <button class="btn ghost sm" data-a="save" style="display:none" title="${esc(T('Save the current iptables rules so they survive a reboot'))}">${ICONS.download}${T('Save permanently')}</button>
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}</button>
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button> <button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
<button class="btn primary sm" data-a="add" disabled>${ICONS.plus}${T('Add rule')}</button> <button class="btn primary sm" data-a="add" disabled>${ICONS.plus}${T('Add rule')}</button>
</div> </div>
@@ -2707,6 +2813,7 @@ class NetworkSession {
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div> <div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="virtual"/>${T('Show virtual interfaces')}</label> <label class="check" style="margin:0 6px"><input type="checkbox" data-a="virtual"/>${T('Show virtual interfaces')}</label>
<button class="btn ghost sm" data-a="files">${ICONS.file}${T('Config files')}</button> <button class="btn ghost sm" data-a="files">${ICONS.file}${T('Config files')}</button>
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}</button>
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button> <button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
<button class="btn primary sm" data-a="new" disabled>${ICONS.plus}${T('New interface')}</button> <button class="btn primary sm" data-a="new" disabled>${ICONS.plus}${T('New interface')}</button>
</div> </div>
+2
View File
@@ -19,6 +19,7 @@
</div> </div>
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)">+</button> <button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)">+</button>
<div class="drag-fill"></div> <div class="drag-fill"></div>
<button id="aiBtn" class="icon-btn" data-ai-toggle title="Support assistant (Ollama)" data-i18n-title="Support assistant (Ollama)"><svg viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/><path d="M9 10h.01M12 10h.01M15 10h.01"/></svg></button>
<button id="lockBtn" class="icon-btn lock-btn" style="display:none" title="Lock (Ctrl+Shift+L)" data-i18n-title="Lock (Ctrl+Shift+L)"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></button> <button id="lockBtn" class="icon-btn lock-btn" style="display:none" title="Lock (Ctrl+Shift+L)" data-i18n-title="Lock (Ctrl+Shift+L)"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></button>
<div class="win-ctrls"> <div class="win-ctrls">
<button id="winMin" title="Minimize" data-i18n-title="Minimize"><svg viewBox="0 0 12 12"><path d="M2 6h8"/></svg></button> <button id="winMin" title="Minimize" data-i18n-title="Minimize"><svg viewBox="0 0 12 12"><path d="M2 6h8"/></svg></button>
@@ -52,6 +53,7 @@
<div id="sessions"></div> <div id="sessions"></div>
</main> </main>
<aside id="aiPanel" class="ai-panel"></aside>
<aside id="drawer" class="drawer"></aside> <aside id="drawer" class="drawer"></aside>
</div> </div>
</div> </div>
+9 -2
View File
@@ -198,13 +198,19 @@ body.in-session #sidebar { display: none; }
.snip-tpl .it:hover { background: var(--card-hover); } .snip-tpl .it:hover { background: var(--card-hover); }
.snip-tpl .hint { color: var(--muted); font-size: 12px; margin-top: 3px; } .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; } .snip-panel .it .mono { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
/* Support-Assistent */ /* Support-Assistent (rechts neben allen Tabs) */
.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 { width: 400px; 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.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 .head { padding: 8px 12px; font-weight: 600; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
.ai-panel .head > span:first-child { display: flex; align-items: center; gap: 8px; }
.ai-panel .head svg { width: 16px; height: 16px; }
.ai-panel .bar { padding: 8px 12px; border-bottom: 1px solid var(--border); }
.ai-panel .bar select { width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; color: inherit; }
.ai-panel .msgs { flex: 1; overflow: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; user-select: text; } .ai-panel .msgs { flex: 1; overflow: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; user-select: text; }
.ai-panel .msgs .hint { color: var(--muted); padding: 8px; }
.ai-panel .m { border-radius: 8px; padding: 8px 10px; line-height: 1.45; overflow-wrap: anywhere; } .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.user { background: var(--accent); color: #fff; align-self: flex-end; max-width: 90%; white-space: pre-wrap; }
.ai-panel .m.user small { display: block; opacity: .75; font-size: 10px; margin-bottom: 2px; }
.ai-panel .m.bot { background: var(--card); } .ai-panel .m.bot { background: var(--card); }
.ai-panel .m.err { color: var(--red, #ff6b6b); } .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 .m code { font-family: var(--mono, monospace); font-size: 12px; background: var(--bg); padding: 1px 4px; border-radius: 4px; }
@@ -213,6 +219,7 @@ body.in-session #sidebar { display: none; }
.ai-panel .code pre { margin: 0; padding: 8px; overflow: auto; } .ai-panel .code pre { margin: 0; padding: 8px; overflow: auto; }
.ai-panel .code pre code { background: none; padding: 0; } .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 { border-top: 1px solid var(--border); padding: 8px; display: flex; flex-direction: column; gap: 6px; }
.ai-panel .ask .check { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
.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 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 .ask .row { justify-content: space-between; }
.ai-panel .spinner { width: 16px; height: 16px; } .ai-panel .spinner { width: 16px; height: 16px; }