- fix: _oldestLoadedMessageId → _oldestLoadedSortKey (chat.js, chat-page.js) - move: formatTimestamp nach ui-helpers.js (war _formatTimestamp in msg-actions.js) - move: _isReviewMessage nach ui-helpers.js (war in history.js) - add: _getSubAgentMeta + _createSubAgentHeader in ui-helpers.js - refactor: stream-ui.js + history.js nutzen _createSubAgentHeader - refactor: flushText() Hilfsfunktion in _renderReviewGroup (history.js) - refactor: shared-page.js nutzt _mergeConsecutiveAssistantTexts statt eigener Kopie - refactor: _initCardMenu + _onCardMenuAction Helper in dashboard.js -53 Zeilen netto
404 lines
13 KiB
JavaScript
404 lines
13 KiB
JavaScript
// ── Chat Page ──
|
||
|
||
let _streamingPollInterval = null;
|
||
let _recoveryChainId = null;
|
||
let _emojiPickerEl = null;
|
||
let _emojiPickerCloseHandler = null;
|
||
|
||
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;
|
||
|
||
// 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;
|
||
});
|
||
}
|
||
|
||
// 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 && 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', () => history.back());
|
||
}
|
||
|
||
// 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();
|
||
|
||
// 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;
|
||
setMetaHtml('<span><i class="fa fa-hourglass-half"></i> Streaming...</span>');
|
||
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;
|
||
closeEmojiPicker();
|
||
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();
|
||
}
|
||
});
|