Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08c6d65e29 | ||
|
|
8c8dc0d4c0 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.16.0",
|
"version": "0.17.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.16.0",
|
"version": "0.17.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"productName": "MrTerm",
|
"productName": "MrTerm",
|
||||||
"version": "0.16.0",
|
"version": "0.17.0",
|
||||||
"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",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { SshManager } = require('../main/ssh');
|
|||||||
const { DockerManager } = require('../main/docker');
|
const { DockerManager } = require('../main/docker');
|
||||||
const { FirewallManager } = require('../main/firewall');
|
const { FirewallManager } = require('../main/firewall');
|
||||||
const { NetworkConfigManager } = require('../main/network');
|
const { NetworkConfigManager } = require('../main/network');
|
||||||
|
const { AiAssistant } = require('../main/ai');
|
||||||
const i18n = require('../i18n');
|
const i18n = require('../i18n');
|
||||||
const backup = require('./backup');
|
const backup = require('./backup');
|
||||||
const composeTemplates = require('../main/compose-templates');
|
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 docker = new DockerManager(ssh);
|
||||||
const firewall = new FirewallManager(ssh);
|
const firewall = new FirewallManager(ssh);
|
||||||
const network = new NetworkConfigManager(ssh);
|
const network = new NetworkConfigManager(ssh);
|
||||||
|
const ai = new AiAssistant(store, send);
|
||||||
let sync = null;
|
let sync = null;
|
||||||
if (withSync) {
|
if (withSync) {
|
||||||
const { SyncService } = require('../main/sync');
|
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:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify));
|
||||||
handle('network:close', (id) => network.close(id));
|
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 ----------
|
// ---------- Synchronisation ----------
|
||||||
if (sync) {
|
if (sync) {
|
||||||
handle('sync:status', () => sync.status());
|
handle('sync:status', () => sync.status());
|
||||||
@@ -221,6 +228,7 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa
|
|||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
ssh.closeAll?.();
|
ssh.closeAll?.();
|
||||||
|
ai.stopAll();
|
||||||
sync?.stop();
|
sync?.stop();
|
||||||
for (const r of pending.values()) r(null);
|
for (const r of pending.values()) r(null);
|
||||||
pending.clear();
|
pending.clear();
|
||||||
|
|||||||
+19
@@ -151,6 +151,25 @@
|
|||||||
'Send Enter automatically': 'Enter automatisch senden',
|
'Send Enter automatically': 'Enter automatisch senden',
|
||||||
'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 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',
|
||||||
|
'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',
|
||||||
|
'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.',
|
'No snippets yet.': 'Noch keine Snippets.',
|
||||||
|
|
||||||
// Port Forwarding
|
// Port Forwarding
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// 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 works in an SSH terminal on a Linux server (typically Ubuntu, Debian or Proxmox).
|
||||||
|
You may receive the latest terminal output as context. 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\nLatest terminal output:\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 };
|
||||||
@@ -18,6 +18,7 @@ const { NetworkConfigManager } = require('./network');
|
|||||||
const { SyncService } = require('./sync');
|
const { SyncService } = require('./sync');
|
||||||
const backup = require('../core/backup');
|
const backup = require('../core/backup');
|
||||||
const composeTemplates = require('./compose-templates');
|
const composeTemplates = require('./compose-templates');
|
||||||
|
const { AiAssistant } = require('./ai');
|
||||||
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
||||||
|
|
||||||
let win;
|
let win;
|
||||||
@@ -77,6 +78,7 @@ const vpn = new VpnManager(store, app.getPath('userData'));
|
|||||||
const docker = new DockerManager(ssh);
|
const docker = new DockerManager(ssh);
|
||||||
const firewall = new FirewallManager(ssh);
|
const firewall = new FirewallManager(ssh);
|
||||||
const network = new NetworkConfigManager(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
|
// LAN-Synchronisation; Vergleichscode beim Koppeln bestätigt der Nutzer in der Oberfläche
|
||||||
const pairReplies = new Map();
|
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:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
|
||||||
handle('sync:probe', (address) => sync.probe(address));
|
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 ----------
|
// ---------- VPN ----------
|
||||||
handle('vpn:status', () => vpn.status());
|
handle('vpn:status', () => vpn.status());
|
||||||
handle('vpn:up', (id) => vpn.up(id));
|
handle('vpn:up', (id) => vpn.up(id));
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ const DEFAULTS = {
|
|||||||
language: 'auto',
|
language: 'auto',
|
||||||
dockerStacksDir: '/opt/stacks',
|
dockerStacksDir: '/opt/stacks',
|
||||||
dockerTemplatesUrl: '',
|
dockerTemplatesUrl: '',
|
||||||
|
aiUrl: 'http://localhost:11434',
|
||||||
|
aiModel: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+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>',
|
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>',
|
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>',
|
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>',
|
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'];
|
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 });
|
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 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);
|
const emb = check(T('Show RDP connections as tabs in MrTerm'), 'rdpEmbed', st.rdpEmbed !== false);
|
||||||
emb.onchange = async () => {
|
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>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>+/−/0</kbd></span><span>${T('Font size')}</span>
|
||||||
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
|
<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);
|
if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, aiCard, dockerCard, importCard, dataCard, keysCard);
|
||||||
else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, dockerCard, importCard, dataCard, updCard, keysCard);
|
else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, aiCard, rdpCard, dockerCard, importCard, dataCard, updCard, keysCard);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================ App-Sperre
|
// ============================================================ 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>
|
<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-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();
|
||||||
@@ -1630,6 +1660,9 @@ 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);
|
||||||
@@ -1637,6 +1670,16 @@ 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);
|
||||||
@@ -1655,6 +1698,38 @@ class TerminalSession {
|
|||||||
box.append(templateList(this));
|
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) {
|
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');
|
||||||
@@ -1727,7 +1802,17 @@ 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.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); }
|
function openTerminal(host) { return new TerminalSession(host); }
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,24 @@ 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 */
|
||||||
|
.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 { 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.open { display: flex; }
|
||||||
.findbar input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; outline: none; width: 200px; }
|
.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