Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecaf579ccd | ||
|
|
4176cc0a01 | ||
|
|
20506ee889 | ||
|
|
6ebdc5126f | ||
|
|
08c6d65e29 | ||
|
|
8c8dc0d4c0 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"version": "0.16.0",
|
||||
"version": "0.18.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mrterm",
|
||||
"version": "0.16.0",
|
||||
"version": "0.18.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"productName": "MrTerm",
|
||||
"version": "0.16.0",
|
||||
"version": "0.18.0",
|
||||
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
||||
"main": "src/main/main.js",
|
||||
"author": "MrBlake",
|
||||
|
||||
@@ -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();
|
||||
|
||||
+29
@@ -151,6 +151,35 @@
|
||||
'Send Enter automatically': 'Enter automatisch senden',
|
||||
'Command is missing.': 'Befehl fehlt.',
|
||||
'No open terminal': 'Kein offenes Terminal',
|
||||
// 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)',
|
||||
'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',
|
||||
'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
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// 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 };
|
||||
+4
-1
@@ -164,7 +164,10 @@ class DockerManager {
|
||||
async readStack(id, file) {
|
||||
const s = this.get(id);
|
||||
const f = absPath(file);
|
||||
const r = await this.sh(s, `cat ${q(f)} && printf '\\n\\0' && { cat ${q(dirname(f) + '/.env')} 2>/dev/null || true; }`);
|
||||
// .env wird mit umask 077 geschrieben – beim Speichern per sudo gehört sie root. Dann scheitert cat mit
|
||||
// „Permission denied“ und sh() wiederholt das Lesen per sudo (vorher wurde der Fehler verschluckt).
|
||||
const env = q(dirname(f) + '/.env');
|
||||
const r = await this.sh(s, `cat ${q(f)} && printf '\\n\\0' && { [ ! -e ${env} ] || cat ${env}; }`);
|
||||
if (r.code) throw new Error(lastLine(r.err) || i18n.t('Could not read {file}', { file: f }));
|
||||
const i = r.out.lastIndexOf('\n\0');
|
||||
return { compose: r.out.slice(0, i), env: r.out.slice(i + 2) };
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -16,6 +16,8 @@ const DEFAULTS = {
|
||||
vpns: [],
|
||||
knownHosts: {},
|
||||
history: [],
|
||||
// Chats des Support-Assistenten (nur lokal, nicht synchronisiert)
|
||||
aiChats: [],
|
||||
// Löschvermerke für die Synchronisation: { c: Collection, id, at }
|
||||
tombstones: [],
|
||||
// LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert)
|
||||
@@ -41,6 +43,8 @@ const DEFAULTS = {
|
||||
language: 'auto',
|
||||
dockerStacksDir: '/opt/stacks',
|
||||
dockerTemplatesUrl: '',
|
||||
aiUrl: 'http://localhost:11434',
|
||||
aiModel: '',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+194
-2
@@ -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'];
|
||||
@@ -150,6 +151,7 @@ async function reload() {
|
||||
S.vault = await call('vault:get');
|
||||
I18N.setLanguage(settings().language, navigator.language);
|
||||
applyStatic();
|
||||
initAi();
|
||||
const b = $('#vaultBadge');
|
||||
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).');
|
||||
@@ -1051,6 +1053,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, 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 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 +1156,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
|
||||
@@ -1545,6 +1569,7 @@ function activateTab(id) {
|
||||
});
|
||||
$('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
renderTree();
|
||||
if ($('#aiPanel').classList.contains('open')) aiUpdateCtx();
|
||||
}
|
||||
function closeTab(id) {
|
||||
const i = S.tabs.findIndex((t) => t.id === id);
|
||||
@@ -1569,6 +1594,7 @@ 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-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>
|
||||
</div>
|
||||
<div class="term-wrap"><div class="term"></div>
|
||||
@@ -1655,6 +1681,13 @@ class TerminalSession {
|
||||
box.append(templateList(this));
|
||||
}
|
||||
|
||||
// Letzte Zeilen des Terminals als Kontext für den Assistenten
|
||||
aiContext(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();
|
||||
}
|
||||
|
||||
toggleFind(open) {
|
||||
const fb = $('.findbar', this.el);
|
||||
const o = open ?? !fb.classList.contains('open');
|
||||
@@ -1729,8 +1762,158 @@ class TerminalSession {
|
||||
}
|
||||
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
|
||||
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); }
|
||||
|
||||
// ============================================================ 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:')} <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)
|
||||
// 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.
|
||||
@@ -2098,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>
|
||||
<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>
|
||||
<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>
|
||||
</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>`);
|
||||
@@ -2372,6 +2556,12 @@ class DockerSession {
|
||||
} 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
|
||||
editStack(st) {
|
||||
this.editing = st;
|
||||
@@ -2429,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="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-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 primary sm" data-a="add" disabled>${ICONS.plus}${T('Add rule')}</button>
|
||||
</div>
|
||||
@@ -2622,6 +2813,7 @@ class NetworkSession {
|
||||
<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>
|
||||
<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 primary sm" data-a="new" disabled>${ICONS.plus}${T('New interface')}</button>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -52,6 +53,7 @@
|
||||
<div id="sessions"></div>
|
||||
</main>
|
||||
|
||||
<aside id="aiPanel" class="ai-panel"></aside>
|
||||
<aside id="drawer" class="drawer"></aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -198,6 +198,31 @@ 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 (rechts neben allen Tabs) */
|
||||
.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 .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 .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.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.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 .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 .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; }
|
||||
|
||||
Reference in New Issue
Block a user