Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20506ee889 | ||
|
|
6ebdc5126f | ||
|
|
08c6d65e29 | ||
|
|
8c8dc0d4c0 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"version": "0.16.0",
|
||||
"version": "0.17.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mrterm",
|
||||
"version": "0.16.0",
|
||||
"version": "0.17.1",
|
||||
"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.17.1",
|
||||
"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();
|
||||
|
||||
+19
@@ -151,6 +151,25 @@
|
||||
'Send Enter automatically': 'Enter automatisch senden',
|
||||
'Command is missing.': 'Befehl fehlt.',
|
||||
'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.',
|
||||
|
||||
// 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 };
|
||||
+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));
|
||||
|
||||
@@ -41,6 +41,8 @@ const DEFAULTS = {
|
||||
language: 'auto',
|
||||
dockerStacksDir: '/opt/stacks',
|
||||
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>',
|
||||
wall: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M3 9.3h18M3 14.7h18M9 4v5.3M15 4v5.3M6 9.3v5.4M12 9.3v5.4M18 9.3v5.4M9 14.7V20M15 14.7V20"/></svg>',
|
||||
network: '<svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1"/><rect x="3" y="16" width="6" height="5" rx="1"/><rect x="15" y="16" width="6" height="5" rx="1"/><path d="M12 8v4M6 16v-4h12v4"/></svg>',
|
||||
ai: '<svg viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/><path d="M9 10h.01M12 10h.01M15 10h.01"/></svg>',
|
||||
logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>',
|
||||
};
|
||||
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
|
||||
@@ -1051,6 +1052,28 @@ async function viewSettings(page) {
|
||||
save({ ...v, fontSize: Number(v.fontSize) || 14, scrollback: Number(v.scrollback) || 10000, keepAlive: Number(v.keepAlive) || 0 });
|
||||
});
|
||||
|
||||
// ---- Support-Assistent (Ollama)
|
||||
const aiCard = h(`<div class="settings-card"><h3>${T('Support assistant')}</h3><p style="color:var(--muted);margin-top:0">${T('Helps with errors in the Linux console via a local <b>Ollama</b> instance. Open it in a terminal tab with the <i>Assistant</i> button. The latest terminal output is sent to the Ollama server as context.')}</p></div>`);
|
||||
const aiUrl = field(T('Ollama server'), 'aiUrl', st.aiUrl || 'http://localhost:11434', { placeholder: 'http://localhost:11434' });
|
||||
const aiModel = field(T('Model'), 'aiModel', st.aiModel, { type: 'select', options: [[st.aiModel || '', st.aiModel || T('– select –')]] });
|
||||
const loadModels = async () => {
|
||||
const sel = $('select', aiModel);
|
||||
try {
|
||||
const list = await api.call('ai:models', $('input', aiUrl).value.trim());
|
||||
sel.innerHTML = '';
|
||||
if (!list.length) sel.append(new Option(T('No models installed (ollama pull …)'), ''));
|
||||
list.forEach((m) => sel.append(new Option(m, m)));
|
||||
sel.value = list.includes(st.aiModel) ? st.aiModel : list[0] || '';
|
||||
if (sel.value !== (settings().aiModel || '')) save({ aiModel: sel.value });
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
const aiReload = h(`<button class="btn">${ICONS.refresh}${T('Load models')}</button>`);
|
||||
aiReload.onclick = loadModels;
|
||||
$('input', aiUrl).onchange = () => save({ aiUrl: $('input', aiUrl).value.trim() }).then(loadModels);
|
||||
$('select', aiModel).onchange = () => save({ aiModel: $('select', aiModel).value });
|
||||
aiCard.append(aiUrl, aiModel, aiReload);
|
||||
if (st.aiModel) loadModels();
|
||||
|
||||
const rdpCard = h(`<div class="settings-card"><h3>RDP</h3><div class="hint rdp-info" style="color:var(--muted);margin-bottom:12px">${T('Checking …')}</div></div>`);
|
||||
const emb = check(T('Show RDP connections as tabs in MrTerm'), 'rdpEmbed', st.rdpEmbed !== false);
|
||||
emb.onchange = async () => {
|
||||
@@ -1132,8 +1155,8 @@ async function viewSettings(page) {
|
||||
<span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span>
|
||||
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span>
|
||||
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
|
||||
if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, dockerCard, importCard, dataCard, keysCard);
|
||||
else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, dockerCard, importCard, dataCard, updCard, keysCard);
|
||||
if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, aiCard, dockerCard, importCard, dataCard, keysCard);
|
||||
else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, aiCard, rdpCard, dockerCard, importCard, dataCard, updCard, keysCard);
|
||||
}
|
||||
|
||||
// ============================================================ App-Sperre
|
||||
@@ -1569,13 +1592,20 @@ class TerminalSession {
|
||||
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
|
||||
${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button><button class="btn ghost sm" data-a="firewall" title="${T('Firewall')}">${ICONS.wall}${T('Firewall')}</button><button class="btn ghost sm" data-a="network" title="${T('Network')}">${ICONS.network}${T('Network')}</button>`}
|
||||
<button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button>
|
||||
<button class="btn ghost sm" data-a="ai" title="${T('Support assistant (Ollama)')}">${ICONS.ai}${T('Assistant')}</button>
|
||||
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
|
||||
</div>
|
||||
<div class="term-wrap"><div class="term"></div>
|
||||
<div class="findbar"><input placeholder="${T('Search …')}"/><button class="btn ghost sm" data-f="prev">↑</button><button class="btn ghost sm" data-f="next">↓</button><button class="btn ghost sm" data-f="close">${ICONS.close}</button></div>
|
||||
<div class="snip-panel"><div class="head">Snippets<button class="btn ghost sm" data-a="snipnew">${ICONS.plus}</button></div><div class="items"></div></div>
|
||||
<div class="ai-panel"><div class="head">${T('Support assistant')}<span><button class="btn ghost sm" data-a="aiclear" title="${T('New chat')}">${ICONS.refresh}</button><button class="btn ghost sm" data-a="ai">${ICONS.close}</button></span></div>
|
||||
<div class="msgs"></div>
|
||||
<form class="ask"><label class="check"><input type="checkbox" name="ctx" checked/>${T('Include terminal output')}</label>
|
||||
<textarea rows="3" placeholder="${T('Describe the problem … (Enter = send)')}"></textarea>
|
||||
<div class="row"><button type="button" class="btn sm" data-a="aierr">${T('Explain last error')}</button><button class="btn primary sm" type="submit">${T('Send')}</button></div></form></div>
|
||||
</div></div>`);
|
||||
this.overlay = null;
|
||||
this.chat = [];
|
||||
this.alive = false;
|
||||
this.initTerm();
|
||||
this.bindUi();
|
||||
@@ -1630,6 +1660,9 @@ class TerminalSession {
|
||||
if (a === 'network') openNetwork(this.host);
|
||||
if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); }
|
||||
if (a === 'snipnew') editSnippet();
|
||||
if (a === 'ai') { $('.ai-panel', this.el).classList.toggle('open'); this.doFit(); if ($('.ai-panel', this.el).classList.contains('open')) $('.ai-panel textarea', this.el).focus(); }
|
||||
if (a === 'aiclear') { api.call('ai:stop', this.id); this.chat = []; $('.ai-panel .msgs', this.el).innerHTML = ''; }
|
||||
if (a === 'aierr') this.askAi(T('Explain the last error in the terminal output and how to fix it.'));
|
||||
if (a === 'find') this.toggleFind();
|
||||
const f = e.target.closest('[data-f]')?.dataset.f;
|
||||
const inp = $('.findbar input', this.el);
|
||||
@@ -1637,6 +1670,16 @@ class TerminalSession {
|
||||
if (f === 'prev') this.search.findPrevious(inp.value);
|
||||
if (f === 'close') this.toggleFind(false);
|
||||
});
|
||||
const ta = $('.ai-panel textarea', this.el);
|
||||
$('.ai-panel form', this.el).onsubmit = (e) => { e.preventDefault(); if (ta.value.trim()) { this.askAi(ta.value.trim()); ta.value = ''; } };
|
||||
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('.ai-panel form', this.el).requestSubmit(); } });
|
||||
$('.ai-panel .msgs', this.el).addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-cmd]'); if (!b) return;
|
||||
const code = b.closest('.code').querySelector('code').textContent.replace(/\n+$/, '');
|
||||
if (b.dataset.cmd === 'copy') { api.call('clipboard:write', code); toast(T('Copied')); }
|
||||
// Nur einfügen, nicht ausführen – der Nutzer prüft und bestätigt mit Enter
|
||||
else { this.term.paste(code); this.term.focus(); }
|
||||
});
|
||||
$('.findbar input', this.el).addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') (e.shiftKey ? this.search.findPrevious : this.search.findNext).call(this.search, e.target.value);
|
||||
if (e.key === 'Escape') this.toggleFind(false);
|
||||
@@ -1655,6 +1698,38 @@ class TerminalSession {
|
||||
box.append(templateList(this));
|
||||
}
|
||||
|
||||
// Letzte Zeilen des Terminals als Kontext für den Assistenten
|
||||
termContext(lines = 120) {
|
||||
const b = this.term.buffer.active, out = [];
|
||||
for (let i = Math.max(0, b.length - lines); i < b.length; i++) out.push(b.getLine(i)?.translateToString(true) ?? '');
|
||||
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
async askAi(text) {
|
||||
const panel = $('.ai-panel', this.el), box = $('.msgs', panel);
|
||||
if (!panel.classList.contains('open')) { panel.classList.add('open'); this.doFit(); }
|
||||
if (!settings().aiModel) { toast(T('Please select an Ollama model in the settings first.'), 'error'); return; }
|
||||
this.chat.push({ role: 'user', content: text });
|
||||
box.append(h(`<div class="m user">${esc(text)}</div>`));
|
||||
const out = h('<div class="m bot"><div class="spinner"></div></div>'); box.append(out);
|
||||
box.scrollTop = box.scrollHeight;
|
||||
const reply = { role: 'assistant', content: '' };
|
||||
this.aiUnsub?.();
|
||||
this.aiUnsub = api.on('ai:event', (cid, type, payload) => {
|
||||
if (cid !== this.id) return;
|
||||
const stick = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
|
||||
if (type === 'data') { reply.content += payload; out.innerHTML = aiMarkdown(reply.content); }
|
||||
else {
|
||||
this.aiUnsub(); this.aiUnsub = null;
|
||||
if (type === 'error') { out.classList.add('err'); out.textContent = payload; this.chat.pop(); }
|
||||
else this.chat.push(reply);
|
||||
}
|
||||
if (stick) box.scrollTop = box.scrollHeight;
|
||||
});
|
||||
try { await api.call('ai:chat', this.id, this.chat.slice(-20), $('[name=ctx]', panel).checked ? this.termContext() : ''); }
|
||||
catch (e) { this.aiUnsub?.(); this.aiUnsub = null; out.classList.add('err'); out.textContent = e.message; this.chat.pop(); }
|
||||
}
|
||||
|
||||
toggleFind(open) {
|
||||
const fb = $('.findbar', this.el);
|
||||
const o = open ?? !fb.classList.contains('open');
|
||||
@@ -1727,7 +1802,17 @@ class TerminalSession {
|
||||
this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`);
|
||||
}
|
||||
}
|
||||
dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); }
|
||||
dispose() { this.unsub(); this.aiUnsub?.(); api.call('ai:stop', this.id).catch(() => {}); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); }
|
||||
}
|
||||
// Minimales Markdown für Assistenten-Antworten: Codeblöcke (mit Kopieren/Einfügen), Inline-Code, Fett
|
||||
function aiMarkdown(md) {
|
||||
return md.split(/```/).map((part, i) => {
|
||||
if (i % 2) {
|
||||
const code = part.replace(/^[\w-]*\n/, '');
|
||||
return `<div class="code"><div class="acts"><button class="btn ghost sm" data-cmd="copy">${T('Copy')}</button><button class="btn ghost sm" data-cmd="paste" title="${T('Insert into terminal (not executed)')}">${T('Insert')}</button></div><pre><code>${esc(code)}</code></pre></div>`;
|
||||
}
|
||||
return esc(part).replace(/`([^`\n]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/\n/g, '<br>');
|
||||
}).join('');
|
||||
}
|
||||
function openTerminal(host) { return new TerminalSession(host); }
|
||||
|
||||
|
||||
@@ -198,6 +198,24 @@ body.in-session #sidebar { display: none; }
|
||||
.snip-tpl .it:hover { background: var(--card-hover); }
|
||||
.snip-tpl .hint { color: var(--muted); font-size: 12px; margin-top: 3px; }
|
||||
.snip-panel .it .mono { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
|
||||
/* Support-Assistent */
|
||||
.ai-panel { width: 380px; border-left: 1px solid var(--border); background: var(--bg-2); display: none; flex-direction: column; flex: none; min-height: 0; }
|
||||
.ai-panel.open { display: flex; }
|
||||
.ai-panel .head { padding: 8px 12px; font-weight: 600; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
|
||||
.ai-panel .msgs { flex: 1; overflow: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; user-select: text; }
|
||||
.ai-panel .m { border-radius: 8px; padding: 8px 10px; line-height: 1.45; overflow-wrap: anywhere; }
|
||||
.ai-panel .m.user { background: var(--accent); color: #fff; align-self: flex-end; max-width: 90%; white-space: pre-wrap; }
|
||||
.ai-panel .m.bot { background: var(--card); }
|
||||
.ai-panel .m.err { color: var(--red, #ff6b6b); }
|
||||
.ai-panel .m code { font-family: var(--mono, monospace); font-size: 12px; background: var(--bg); padding: 1px 4px; border-radius: 4px; }
|
||||
.ai-panel .code { margin: 6px 0; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.ai-panel .code .acts { display: flex; justify-content: flex-end; gap: 2px; padding: 2px; border-bottom: 1px solid var(--border); }
|
||||
.ai-panel .code pre { margin: 0; padding: 8px; overflow: auto; }
|
||||
.ai-panel .code pre code { background: none; padding: 0; }
|
||||
.ai-panel .ask { border-top: 1px solid var(--border); padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.ai-panel .ask textarea { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; resize: vertical; color: inherit; font: inherit; outline: none; }
|
||||
.ai-panel .ask .row { justify-content: space-between; }
|
||||
.ai-panel .spinner { width: 16px; height: 16px; }
|
||||
.findbar { position: absolute; top: 8px; right: 24px; z-index: 4; display: none; gap: 4px; align-items: center; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px #0008; }
|
||||
.findbar.open { display: flex; }
|
||||
.findbar input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; outline: none; width: 200px; }
|
||||
|
||||
Reference in New Issue
Block a user