Files
gott/wwwroot/js/pages/chat-page.js
T
Meleki ce50633e33 refactor: Inline-Styles durch CSS-Klassen ersetzt
- 40+ Utility-Klassen in form.css (clickable, text-danger, flex-row, loading-state, etc.)
- share-menu position/z-index in chat.css statt inline
- index.html: 0 Inline-Styles (hidden-Attribut, CSS-Klassen)
- 12 JS-Dateien bereinigt: style.cssText/style.* durch classList ersetzt
- Bugfix: var(--danger) → var(--dangerous) (nicht existierende Variable)
- display:none Toggles → hidden-Attribut wo passend
- Hardcoded font-sizes in chat.css → var(--font-size)

15 Dateien, nur Frontend (CSS/JS/HTML), kein Backend
2026-03-14 17:14:30 +01:00

633 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── Chat Page ──
let _streamingPollInterval = null;
let _recoveryChainId = null;
let _emojiPickerEl = null;
let _emojiPickerCloseHandler = null;
let _chatUsageKeyId = null;
let _chatUsageScrollHandler = null;
// ── Chat Usage Badge ──
function _createChatUsageBadge(usage5h, usage7d) {
if (usage5h == null && usage7d == null) return null;
var badge = document.createElement('div');
badge.className = 'usage-badge';
function addRow(label, pct) {
if (pct == null) return;
var row = document.createElement('div');
row.className = 'usage-row';
var lbl = document.createElement('span');
lbl.className = 'usage-label';
lbl.textContent = label;
var track = document.createElement('div');
track.className = 'usage-track';
var fill = document.createElement('div');
fill.className = 'usage-fill';
if (pct >= 90) fill.className += ' danger';
else if (pct >= 70) fill.className += ' warn';
fill.style.width = Math.min(pct, 100) + '%';
track.appendChild(fill);
var val = document.createElement('span');
val.className = 'usage-pct';
val.textContent = pct + '%';
row.appendChild(lbl);
row.appendChild(track);
row.appendChild(val);
badge.appendChild(row);
}
addRow('Sitzung', usage5h);
addRow('Woche', usage7d);
return badge;
}
async function _initChatUsageBadge() {
if (!currentUser) return;
var container = document.getElementById('chatUsageBadge');
if (!container) return;
try {
var selectedKey = await apiGetSelectedKey();
if (!selectedKey || !selectedKey.selected) {
container.innerHTML = '';
var noKeyBtn = document.createElement('button');
noKeyBtn.className = 'chat-usage-nokey';
noKeyBtn.innerHTML = '<i class="fa fa-key"></i> Key auswählen';
noKeyBtn.addEventListener('click', function() { _showKeySwitchModal(); });
container.appendChild(noKeyBtn);
container.hidden = false;
return;
}
_chatUsageKeyId = selectedKey.keyId;
_renderChatUsageBadge(container, selectedKey);
container.hidden = false;
// Scroll-Listener: Badge ausblenden wenn User nach oben scrollt
var chatEl = document.getElementById('chat');
if (chatEl) {
_chatUsageScrollHandler = function() {
var threshold = 60;
var atBottom = chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight <= threshold;
container.classList.toggle('hidden', !atBottom);
};
chatEl.addEventListener('scroll', _chatUsageScrollHandler);
}
} catch(e) {
console.error('Usage badge load failed:', e);
}
}
async function _refreshChatUsageBadge() {
if (!currentUser) return;
var container = document.getElementById('chatUsageBadge');
if (!container || !_chatUsageKeyId) return;
try {
var selectedKey = await apiGetSelectedKey();
if (selectedKey) _renderChatUsageBadge(container, selectedKey);
} catch(e) { /* ignore */ }
}
function _renderChatUsageBadge(container, key) {
container.innerHTML = '';
var badge = _createChatUsageBadge(key.usage5h, key.usage7d);
if (!badge) {
badge = document.createElement('div');
badge.className = 'usage-badge usage-badge-nodata';
badge.innerHTML = '<i class="fa fa-key"></i> ' + (key.name || 'API Key');
}
badge.addEventListener('click', function(e) {
e.stopPropagation();
_showKeySwitchModal();
});
badge.title = (key.name || 'API Key') + ' · ' + (key.plan || '?');
badge.classList.add('clickable');
container.appendChild(badge);
// Check-Button nur für Admins
if (currentUser && currentUser.isAdmin) {
var checkBtn = document.createElement('button');
checkBtn.className = 'chat-usage-check icon-btn';
checkBtn.innerHTML = '<i class="fa fa-refresh"></i>';
checkBtn.title = 'Key prüfen';
checkBtn.addEventListener('click', async function(e) {
e.stopPropagation();
checkBtn.disabled = true;
checkBtn.querySelector('i').className = 'fa fa-spinner fa-spin';
try {
await apiCheckApiKey(_chatUsageKeyId);
var selectedKey = await apiGetSelectedKey();
if (selectedKey) _renderChatUsageBadge(container, selectedKey);
} catch(e) {
console.error('Key check failed:', e);
}
checkBtn.disabled = false;
checkBtn.querySelector('i').className = 'fa fa-refresh';
});
container.appendChild(checkBtn);
}
}
async function _showKeySwitchModal() {
var keys = [];
try { keys = await apiGetAvailableKeys(); } catch(e) { return; }
var selectedKey = null;
try { selectedKey = await apiGetSelectedKey(); } catch(e) { /* ignore */ }
var selectedKeyId = selectedKey ? selectedKey.keyId : null;
var { body, close } = _createModal('API Key wechseln', { modal: 'key-switch-modal' });
if (keys.length === 0) {
var empty = document.createElement('div');
empty.className = 'empty-hint';
empty.textContent = 'Keine Keys verfügbar. Ein Admin muss Keys als öffentlich markieren.';
body.appendChild(empty);
return;
}
var list = document.createElement('div');
list.className = 'key-switch-list';
keys.forEach(function(key) {
var row = document.createElement('div');
row.className = 'key-switch-row';
if (key.keyId === selectedKeyId) row.classList.add('active');
var info = document.createElement('div');
info.className = 'key-switch-info';
var name = document.createElement('div');
name.className = 'key-switch-name';
name.textContent = key.name || key.keyId;
info.appendChild(name);
var meta = document.createElement('div');
meta.className = 'key-switch-meta';
var parts = [];
var providerLabels = { anthropic: '🟤 Anthropic', openai: '🟢 OpenAI', google: '🔵 Google' };
parts.push(providerLabels[key.provider] || key.provider || 'Anthropic');
if (key.plan) parts.push(key.plan);
if (key.status) parts.push(key.status === 'valid' ? '✅' : '⚠️');
meta.textContent = parts.join(' · ');
info.appendChild(meta);
row.appendChild(info);
var badge = _createChatUsageBadge(key.usage5h, key.usage7d);
if (badge) {
row.appendChild(badge);
}
if (key.keyId !== selectedKeyId) {
row.classList.add('clickable');
row.addEventListener('click', async function() {
row.classList.add('loading-state');
try {
await apiSelectApiKey(key.keyId);
close();
await _initChatUsageBadge();
} catch(e) {
console.error('Key switch failed:', e);
row.classList.remove('loading-state');
}
});
}
list.appendChild(row);
});
body.appendChild(list);
}
function _buildEmojiPicker() {
const picker = document.createElement('div');
picker.className = 'emoji-picker';
for (const [category, emojis] of Object.entries(EMOJI_USERDATA)) {
const section = document.createElement('div');
section.className = 'emoji-picker-section';
const label = document.createElement('div');
label.className = 'emoji-picker-label';
label.textContent = category;
section.appendChild(label);
const grid = document.createElement('div');
grid.className = 'emoji-picker-grid';
for (const emoji of emojis) {
const btn = document.createElement('button');
btn.className = 'emoji-picker-btn';
btn.textContent = emoji;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const input = document.getElementById('input');
if (input) {
const start = input.selectionStart;
const end = input.selectionEnd;
const val = input.value;
input.value = val.slice(0, start) + emoji + val.slice(end);
const pos = start + emoji.length;
input.setSelectionRange(pos, pos);
input.dispatchEvent(new Event('input'));
input.focus();
}
closeEmojiPicker();
});
// Fluent Emoji im Button rendern
parseFluentEmojis(btn);
grid.appendChild(btn);
}
section.appendChild(grid);
picker.appendChild(section);
}
return picker;
}
function toggleEmojiPicker(anchor) {
if (_emojiPickerEl) {
closeEmojiPicker();
return;
}
const picker = _buildEmojiPicker();
document.body.appendChild(picker);
_emojiPickerEl = picker;
// Positionierung: über dem Button, rechts ausgerichtet
const rect = anchor.getBoundingClientRect();
const pickerHeight = 320;
const pickerWidth = 280;
let top = rect.top - pickerHeight - 6;
let left = rect.right - pickerWidth;
// Falls kein Platz oben → unter dem Button
if (top < 8) top = rect.bottom + 6;
// Falls links abgeschnitten → linksbündig zum Button
if (left < 8) left = 8;
picker.style.top = top + 'px';
picker.style.left = left + 'px';
_emojiPickerCloseHandler = (e) => {
if (!picker.contains(e.target)) closeEmojiPicker();
};
setTimeout(() => document.addEventListener('click', _emojiPickerCloseHandler), 0);
}
function closeEmojiPicker() {
if (_emojiPickerEl) {
_emojiPickerEl.remove();
_emojiPickerEl = null;
}
if (_emojiPickerCloseHandler) {
document.removeEventListener('click', _emojiPickerCloseHandler);
_emojiPickerCloseHandler = null;
}
}
function startStreamingPoll(sessionId, sendBtn, chainId) {
_recoveryChainId = chainId;
// History-Polling stoppen – Streaming-Poll übernimmt
stopHistoryPolling();
if (_streamingPollInterval) clearInterval(_streamingPollInterval);
_streamingPollInterval = setInterval(async () => {
try {
// Neue Nachrichten laden (inkl. der noch streamenden)
await pollHistory(true);
const status = await apiIsStreaming(sessionId);
if (!status.streaming) {
clearInterval(_streamingPollInterval);
_streamingPollInterval = null;
isStreaming = false;
_abortController = null;
if (sendBtn) { sendBtn.disabled = false; sendBtn.hidden = false; }
const stopEl = document.getElementById('stop');
if (stopEl) stopEl.hidden = true;
// Typing-Indicator stoppen
stopTypingIndicator();
// Finale Nachrichten nochmal laden
await pollHistory(true);
// Chain-Daten laden für Meta-Info im Chat
const cid = _recoveryChainId || status.chainId;
if (cid) {
const chain = await apiGetChain(cid);
if (chain) addChainMetaBubble(chain, false);
}
setMeta('Bereit');
_recoveryChainId = null;
// History-Polling wieder starten
startHistoryPolling();
}
} catch (e) {
console.error('Streaming-Poll fehlgeschlagen:', e);
}
}, 1500);
}
registerPage('chat', {
async init(params) {
const sessionId = params.id;
if (!sessionId) return navigateTo('dashboard');
currentSessionId = sessionId;
const session = await apiGetSession(sessionId);
if (!session) return navigateTo('dashboard');
// Kein Agent = kein Chat
if (!session.agentId) {
navigateTo('session', { id: sessionId });
return;
}
const titleEl = document.getElementById('sessionTitle');
if (titleEl) {
titleEl.textContent = session.name || 'Unbenannt';
}
// Header-Menü (3-Dot)
const chatMenuBtn = document.getElementById('chatMenuBtn');
const chatMenuDropdown = document.getElementById('chatMenuDropdown');
if (chatMenuBtn && chatMenuDropdown) {
chatMenuBtn.addEventListener('click', (e) => {
e.stopPropagation();
chatMenuDropdown.hidden = !chatMenuDropdown.hidden;
});
document.addEventListener('click', this._closeMenu = () => {
chatMenuDropdown.hidden = true;
});
}
// Remote-Verbindungen
const remoteBtn = document.getElementById('remoteBtn');
if (remoteBtn) remoteBtn.addEventListener('click', () => {
if (chatMenuDropdown) chatMenuDropdown.hidden = true;
showRemoteModal(sessionId);
});
// System-Prompt anzeigen
const systemPromptBtn = document.getElementById('systemPromptBtn');
if (systemPromptBtn) systemPromptBtn.addEventListener('click', () => {
if (chatMenuDropdown) chatMenuDropdown.hidden = true;
showSystemPromptModal(sessionId);
});
// Archivieren
const archiveBtn = document.getElementById('archiveBtn');
if (archiveBtn) archiveBtn.addEventListener('click', () => {
if (chatMenuDropdown) chatMenuDropdown.hidden = true;
clearChat();
});
// Custom Command Buttons ins Header-Menü einfügen
if (chatMenuDropdown) {
// "System"-Label ganz oben einfügen
const systemLabel = document.createElement('div');
systemLabel.className = 'header-menu-label';
systemLabel.textContent = 'System';
chatMenuDropdown.prepend(systemLabel);
let agentCmds = [];
if (session.agentId) {
try {
const agent = await apiGetAgent(session.agentId);
if (agent) {
setCurrentAgentName(agent.name || null);
if (agent.customCommands) {
agentCmds = JSON.parse(agent.customCommands) || [];
}
}
} catch(e) {}
}
let sessionCmds = [];
try { sessionCmds = JSON.parse(session.customCommands || '[]') || []; } catch(e) {}
const addCmdSection = (cmds, label) => {
const validCmds = cmds.filter(c => c.name && c.command);
if (validCmds.length === 0) return;
const sep = document.createElement('div');
sep.className = 'header-menu-separator';
chatMenuDropdown.appendChild(sep);
const lbl = document.createElement('div');
lbl.className = 'header-menu-label';
lbl.textContent = label;
chatMenuDropdown.appendChild(lbl);
validCmds.forEach(cmd => {
if (!cmd.name || !cmd.command) return;
const type = cmd.type || 'background';
const btn = document.createElement('button');
btn.className = 'header-menu-item header-menu-cmd';
btn.textContent = cmd.name;
btn.title = type === 'message' ? cmd.command : `[${type}] ${cmd.command}`;
btn.addEventListener('click', async () => {
chatMenuDropdown.hidden = true;
if (type === 'message') {
const inputEl = document.getElementById('input');
if (inputEl) {
inputEl.value = cmd.command;
inputEl.dispatchEvent(new Event('input'));
}
sendMessage();
return;
}
btn.disabled = true;
btn.classList.add('running');
try {
if (type === 'foreground') {
const result = await apiExecCommand(sessionId, cmd.command, 'foreground');
showCommandOutputModal(cmd.name, cmd.command, result);
} else {
await apiExecCommand(sessionId, cmd.command, 'background');
}
} catch(e) {
console.error('Exec failed:', e);
}
btn.disabled = false;
btn.classList.remove('running');
});
chatMenuDropdown.appendChild(btn);
});
};
addCmdSection(agentCmds, 'Agent');
addCmdSection(sessionCmds, 'Session');
}
// Zurück zum Dashboard
const backBtn = document.getElementById('chatBackBtn');
if (backBtn) {
backBtn.addEventListener('click', () => {
if (history.state && history.length > 2) {
history.back();
} else {
navigateTo('dashboard');
}
});
}
// Send / Stop
const sendBtn = document.getElementById('send');
if (sendBtn) sendBtn.addEventListener('click', sendMessage);
const stopBtn = document.getElementById('stop');
if (stopBtn) stopBtn.addEventListener('click', stopStreaming);
const inputEl = document.getElementById('input');
if (inputEl) {
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
sendMessage();
}
});
inputEl.addEventListener('input', function () {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 150) + 'px';
});
inputEl.dispatchEvent(new Event('input'))
}
// Emoji Picker
const emojiBtn = document.getElementById('emojiBtn');
if (emojiBtn) {
emojiBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleEmojiPicker(emojiBtn);
});
}
// Workspace Explorer
const explorerBtn = document.getElementById('explorerBtn');
if (explorerBtn) {
explorerBtn.addEventListener('click', () => {
openFileExplorer({
sessionId,
onSelect(ref) {
addPendingRef(ref);
document.getElementById('input')?.focus();
}
});
});
}
// Plan Mode Toggle
const planModeBtn = document.getElementById('planModeBtn');
if (planModeBtn) {
// Plan-Modus bei Session-Wechsel zurücksetzen
_planModeActive = false;
planModeBtn.classList.remove('plan-mode-active');
planModeBtn.addEventListener('click', togglePlanMode);
}
// File Upload
const fileBtn = document.getElementById('fileBtn');
const fileInput = document.getElementById('fileInput');
if (fileBtn && fileInput) {
fileBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) handleMultipleFiles(e.target.files);
});
}
// Drag & Drop
const chatContainer = document.querySelector('.chat-page');
if (chatContainer) {
chatContainer.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); });
chatContainer.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.files.length > 0) handleMultipleFiles(e.dataTransfer.files);
});
}
// Paste
if (inputEl) {
inputEl.addEventListener('paste', (e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.kind === 'file') {
e.preventDefault();
handleFileSelect(item.getAsFile());
return;
}
}
});
}
// State zurücksetzen und History laden
lastKnownMessageId = 0;
loadedArchiveCount = 0;
_oldestLoadedSortKey = 0;
_hasMoreOlderMessages = false;
isStreaming = false;
await loadHistory();
// Usage Badge laden (Admin only)
_initChatUsageBadge();
// Nach Font-Loading nochmal scrollen (Schriftart kann Höhe verändern)
document.fonts.ready.then(() => {
requestAnimationFrame(() => scrollDown());
});
// Prüfen ob Backend gerade noch streamt (z.B. nach Page-Refresh)
try {
const status = await apiIsStreaming(sessionId);
if (status.streaming) {
isStreaming = true;
if (sendBtn) { sendBtn.disabled = true; sendBtn.hidden = true; }
const stopEl = document.getElementById('stop');
if (stopEl) stopEl.hidden = false;
startTypingIndicator();
startStreamingPoll(sessionId, sendBtn, status.chainId);
}
} catch (e) {
console.error('Streaming-Status prüfen fehlgeschlagen:', e);
}
},
destroy() {
// Stream nicht abbrechen (Backend soll weiterlaufen),
// aber Frontend-Processing stoppen
_chatAbandoned = true;
stopTypingIndicator();
setCurrentAgentName(null);
closeEmojiPicker();
// Usage Badge cleanup
if (_chatUsageScrollHandler) {
var chatEl = document.getElementById('chat');
if (chatEl) chatEl.removeEventListener('scroll', _chatUsageScrollHandler);
_chatUsageScrollHandler = null;
}
_chatUsageKeyId = null;
if (this._closeMenu) {
document.removeEventListener('click', this._closeMenu);
this._closeMenu = null;
}
if (_streamingPollInterval) {
clearInterval(_streamingPollInterval);
_streamingPollInterval = null;
}
stopHistoryPolling();
lastKnownMessageId = 0;
loadedArchiveCount = 0;
isStreaming = false;
clearPendingFiles();
clearPendingRefs();
clearReply();
}
});