Files
Bjorn/web/js/pages/llm-chat.js
infinition b759ab6d4b Add LLM configuration and MCP server management UI and backend functionality
- Implemented a new SPA page for LLM Bridge and MCP Server settings in `llm-config.js`.
- Added functionality for managing LLM and MCP configurations, including toggling, saving settings, and testing connections.
- Created HTTP endpoints in `llm_utils.py` for handling LLM chat, status checks, and MCP server configuration.
- Integrated model fetching from LaRuche and Ollama backends.
- Enhanced error handling and logging for better debugging and user feedback.
2026-03-16 20:33:22 +01:00

254 lines
8.5 KiB
JavaScript

/**
* llm-chat — LLM Chat SPA page
* Chat interface with LLM bridge + orchestrator reasoning log.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api } from '../core/api.js';
import { el, $, empty, escapeHtml } from '../core/dom.js';
import { t } from '../core/i18n.js';
const PAGE = 'llm-chat';
/* ── State ─────────────────────────────────────────────── */
let tracker = null;
let root = null;
let llmEnabled = false;
let orchMode = false;
const sessionId = 'chat-' + Math.random().toString(36).slice(2, 8);
/* ── Lifecycle ─────────────────────────────────────────── */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
root = buildShell();
container.appendChild(root);
bindEvents();
await checkStatus();
sysMsg(t('llm_chat.session_started'));
}
export function unmount() {
if (tracker) { tracker.cleanupAll(); tracker = null; }
root = null;
llmEnabled = false;
orchMode = false;
}
/* ── Shell ─────────────────────────────────────────────── */
function buildShell() {
return el('div', { class: 'llmc-page' }, [
/* Header */
el('div', { class: 'llmc-header' }, [
el('span', { class: 'llmc-dot', id: 'llmc-dot' }),
el('span', { class: 'llmc-title' }, ['BJORN / CHAT']),
el('span', { class: 'llmc-status', id: 'llmc-status' }, [t('llm_chat.checking')]),
el('button', { class: 'llmc-btn-ghost', id: 'llmc-orch-btn', title: t('llm_chat.orch_title') },
[t('llm_chat.orch_log')]),
el('button', { class: 'llmc-btn-ghost llmc-clear-btn', id: 'llmc-clear-btn' },
[t('llm_chat.clear_history')]),
el('button', { class: 'llmc-btn-ghost', id: 'llmc-cfg-btn', title: 'LLM Settings' },
['\u2699']),
]),
/* Messages */
el('div', { class: 'llmc-messages', id: 'llmc-messages' }, [
el('div', { class: 'llmc-disabled-msg', id: 'llmc-disabled-msg', style: 'display:none' }, [
t('llm_chat.disabled_msg') + ' ',
el('a', { href: '#/llm-config' }, [t('llm_chat.settings_link')]),
'.',
]),
]),
/* Thinking */
el('div', { class: 'llmc-thinking', id: 'llmc-thinking', style: 'display:none' }, [
'▌ ', t('llm_chat.thinking'),
]),
/* Input row */
el('div', { class: 'llmc-input-row', id: 'llmc-input-row' }, [
el('textarea', {
class: 'llmc-input', id: 'llmc-input',
placeholder: t('llm_chat.placeholder'),
rows: '1',
}),
el('button', { class: 'llmc-send-btn', id: 'llmc-send-btn' }, [t('llm_chat.send')]),
]),
]);
}
/* ── Events ────────────────────────────────────────────── */
function bindEvents() {
const sendBtn = $('#llmc-send-btn', root);
const clearBtn = $('#llmc-clear-btn', root);
const orchBtn = $('#llmc-orch-btn', root);
const input = $('#llmc-input', root);
const cfgBtn = $('#llmc-cfg-btn', root);
if (sendBtn) tracker.on(sendBtn, 'click', send);
if (clearBtn) tracker.on(clearBtn, 'click', clearHistory);
if (orchBtn) tracker.on(orchBtn, 'click', toggleOrchLog);
if (cfgBtn) tracker.on(cfgBtn, 'click', () => { window.location.hash = '#/llm-config'; });
if (input) {
tracker.on(input, 'keydown', (e) => {
// Auto-resize
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
});
}
}
/* ── Status ────────────────────────────────────────────── */
async function checkStatus() {
try {
const data = await api.get('/api/llm/status', { timeout: 5000, retries: 0 });
if (!data) throw new Error('no data');
llmEnabled = data.enabled === true;
const dot = $('#llmc-dot', root);
const status = $('#llmc-status', root);
const disMsg = $('#llmc-disabled-msg', root);
const sendBtn = $('#llmc-send-btn', root);
if (!llmEnabled) {
if (dot) dot.className = 'llmc-dot offline';
if (status) status.textContent = t('llm_chat.disabled');
if (disMsg) disMsg.style.display = '';
if (sendBtn) sendBtn.disabled = true;
} else {
if (dot) dot.className = 'llmc-dot online';
const backend = data.laruche_url
? 'LaRuche @ ' + data.laruche_url
: (data.backend || 'auto');
if (status) status.textContent = t('llm_chat.online') + ' · ' + backend;
if (disMsg) disMsg.style.display = 'none';
if (sendBtn) sendBtn.disabled = false;
}
} catch {
const status = $('#llmc-status', root);
if (status) status.textContent = t('llm_chat.unavailable');
}
}
/* ── Chat ──────────────────────────────────────────────── */
async function send() {
const input = $('#llmc-input', root);
const sendBtn = $('#llmc-send-btn', root);
if (!input) return;
const msg = input.value.trim();
if (!msg) return;
input.value = '';
input.style.height = '44px';
appendMsg('user', msg);
setThinking(true);
if (sendBtn) sendBtn.disabled = true;
try {
const data = await api.post('/api/llm/chat', { message: msg, session_id: sessionId });
setThinking(false);
if (data?.status === 'ok') {
appendMsg('assistant', data.response);
} else {
sysMsg(t('llm_chat.error') + ': ' + (data?.message || 'unknown'));
}
} catch (e) {
setThinking(false);
sysMsg(t('llm_chat.net_error') + ': ' + e.message);
} finally {
if (sendBtn) sendBtn.disabled = !llmEnabled;
}
}
async function clearHistory() {
await api.post('/api/llm/clear_history', { session_id: sessionId });
const msgs = $('#llmc-messages', root);
if (!msgs) return;
empty(msgs);
const disMsg = $('#llmc-disabled-msg', root);
if (disMsg) msgs.appendChild(disMsg);
sysMsg(t('llm_chat.history_cleared'));
}
/* ── Orch log ──────────────────────────────────────────── */
async function toggleOrchLog() {
orchMode = !orchMode;
const orchBtn = $('#llmc-orch-btn', root);
const inputRow = $('#llmc-input-row', root);
const msgs = $('#llmc-messages', root);
if (orchMode) {
if (orchBtn) { orchBtn.classList.add('active'); orchBtn.textContent = t('llm_chat.back_chat'); }
if (inputRow) inputRow.style.display = 'none';
if (msgs) empty(msgs);
await loadOrchLog();
} else {
if (orchBtn) { orchBtn.classList.remove('active'); orchBtn.textContent = t('llm_chat.orch_log'); }
if (inputRow) inputRow.style.display = '';
if (msgs) empty(msgs);
sysMsg(t('llm_chat.back_to_chat'));
}
}
async function loadOrchLog() {
sysMsg(t('llm_chat.loading_log'));
try {
const data = await api.get('/api/llm/reasoning', { timeout: 10000, retries: 0 });
const msgs = $('#llmc-messages', root);
if (!msgs) return;
empty(msgs);
if (!data?.messages?.length) {
sysMsg(t('llm_chat.no_log'));
return;
}
sysMsg(t('llm_chat.log_header') + ' — ' + data.count + ' message(s)');
for (const m of data.messages) {
appendMsg(m.role === 'user' ? 'user' : 'assistant', m.content || '');
}
} catch (e) {
sysMsg(t('llm_chat.log_error') + ': ' + e.message);
}
}
/* ── Helpers ───────────────────────────────────────────── */
function appendMsg(role, text) {
const msgs = $('#llmc-messages', root);
if (!msgs) return;
const labels = { user: 'YOU', assistant: 'BJORN' };
const roleLabel = labels[role] || role.toUpperCase();
const div = el('div', { class: 'llmc-msg ' + role }, [
el('div', { class: 'llmc-msg-role' }, [roleLabel]),
document.createTextNode(text),
]);
msgs.appendChild(div);
msgs.scrollTop = msgs.scrollHeight;
}
function sysMsg(text) {
const msgs = $('#llmc-messages', root);
if (!msgs) return;
const div = el('div', { class: 'llmc-msg system' }, [text]);
msgs.appendChild(div);
msgs.scrollTop = msgs.scrollHeight;
}
function setThinking(on) {
const el = $('#llmc-thinking', root);
if (el) el.style.display = on ? '' : 'none';
}