809 lines
27 KiB
JavaScript
809 lines
27 KiB
JavaScript
/**
|
||
* Flynn Chat Page
|
||
*
|
||
* Session selector, message input, streaming tool events,
|
||
* markdown-rendered responses, slash commands, and web search.
|
||
*/
|
||
|
||
/* global hljs */
|
||
import { renderSafeMarkdown } from '../lib/markdown.js';
|
||
|
||
let _currentSession = null;
|
||
let _sending = false;
|
||
let _searchMode = false;
|
||
let _slashPopupIndex = -1;
|
||
let _elements = {};
|
||
let _pendingAttachments = [];
|
||
|
||
// ── Slash Command Definitions ───────────────────────────────
|
||
|
||
const SLASH_COMMANDS = [
|
||
{ name: '/help', desc: 'Show available commands' },
|
||
{ name: '/reset', desc: 'Reset session' },
|
||
{ name: '/compact', desc: 'Compact context' },
|
||
{ name: '/usage', desc: 'Show token usage' },
|
||
{ name: '/status', desc: 'Show system health' },
|
||
{ name: '/model', desc: 'Show current model' },
|
||
];
|
||
|
||
// ── Helpers ─────────────────────────────────────────────────
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function highlightCode() {
|
||
if (typeof hljs !== 'undefined') {
|
||
document.querySelectorAll('.chat-messages pre code').forEach(block => {
|
||
hljs.highlightElement(block);
|
||
});
|
||
}
|
||
}
|
||
|
||
function createMessageEl(role, content) {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'message-wrapper';
|
||
|
||
const div = document.createElement('div');
|
||
div.className = `message ${role}`;
|
||
|
||
if (role === 'assistant' || role === 'system') {
|
||
div.innerHTML = renderSafeMarkdown(content);
|
||
setTimeout(highlightCode, 0);
|
||
} else {
|
||
div.textContent = content;
|
||
}
|
||
wrapper.appendChild(div);
|
||
|
||
// Add action buttons (copy for all, edit for user) outside the message box
|
||
if (role !== 'system') {
|
||
wrapper.appendChild(createMessageActions(role));
|
||
}
|
||
|
||
return wrapper;
|
||
}
|
||
|
||
function createToolEventEl(event, data) {
|
||
const group = document.createElement('div');
|
||
group.className = 'tool-event-group';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'tool-event-header';
|
||
|
||
if (event === 'tool_start') {
|
||
header.innerHTML = `<span class="spinner"></span> <strong>${escapeHtml(data.tool)}</strong>`;
|
||
} else if (event === 'tool_end') {
|
||
const icon = data.result?.success ? '✓' : '✗';
|
||
const cls = data.result?.success ? 'status-ok' : 'status-error';
|
||
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
|
||
}
|
||
|
||
header.addEventListener('click', () => {
|
||
body.classList.toggle('open');
|
||
});
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'tool-event-body';
|
||
|
||
if (event === 'tool_start' && data.args) {
|
||
body.textContent = JSON.stringify(data.args, null, 2);
|
||
} else if (event === 'tool_end' && data.result) {
|
||
body.textContent = data.result.output || data.result.error || '(no output)';
|
||
}
|
||
|
||
group.appendChild(header);
|
||
group.appendChild(body);
|
||
return group;
|
||
}
|
||
|
||
function scrollToBottom() {
|
||
const msgs = _elements.messages;
|
||
if (msgs) {
|
||
msgs.scrollTop = msgs.scrollHeight;
|
||
}
|
||
}
|
||
|
||
function isSupportedImageMime(mimeType) {
|
||
return mimeType === 'image/jpeg'
|
||
|| mimeType === 'image/png'
|
||
|| mimeType === 'image/gif'
|
||
|| mimeType === 'image/webp';
|
||
}
|
||
|
||
function readFileAsBase64(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||
reader.onload = () => {
|
||
const result = String(reader.result || '');
|
||
// result is like: data:<mime>;base64,<payload>
|
||
const comma = result.indexOf(',');
|
||
if (comma === -1) {
|
||
reject(new Error('Unexpected file encoding'));
|
||
return;
|
||
}
|
||
resolve(result.slice(comma + 1));
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
function renderPendingAttachments() {
|
||
const el = _elements.attachments;
|
||
if (!el) {return;}
|
||
|
||
el.innerHTML = '';
|
||
if (!_pendingAttachments.length) {
|
||
el.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
el.classList.remove('hidden');
|
||
for (let i = 0; i < _pendingAttachments.length; i++) {
|
||
const att = _pendingAttachments[i];
|
||
const chip = document.createElement('div');
|
||
chip.className = 'attachment-chip';
|
||
|
||
const name = document.createElement('span');
|
||
name.className = 'attachment-name';
|
||
name.textContent = att.filename || 'attachment';
|
||
|
||
const rm = document.createElement('button');
|
||
rm.className = 'attachment-remove';
|
||
rm.type = 'button';
|
||
rm.title = 'Remove attachment';
|
||
rm.textContent = '×';
|
||
rm.addEventListener('click', () => {
|
||
_pendingAttachments.splice(i, 1);
|
||
renderPendingAttachments();
|
||
});
|
||
|
||
chip.appendChild(name);
|
||
chip.appendChild(rm);
|
||
el.appendChild(chip);
|
||
}
|
||
}
|
||
|
||
function clearPendingAttachments() {
|
||
_pendingAttachments = [];
|
||
if (_elements.fileInput) {
|
||
_elements.fileInput.value = '';
|
||
}
|
||
renderPendingAttachments();
|
||
}
|
||
|
||
// ── Message Action Buttons ──────────────────────────────────
|
||
|
||
function getMessageText(el) {
|
||
// For user messages, textContent is the raw text.
|
||
// For assistant/system messages rendered as markdown, extract plain text.
|
||
return (el.textContent || '').trim();
|
||
}
|
||
|
||
function createMessageActions(role) {
|
||
const bar = document.createElement('div');
|
||
bar.className = 'message-actions';
|
||
|
||
// Copy button — all messages
|
||
const copyBtn = document.createElement('button');
|
||
copyBtn.className = 'msg-action-btn';
|
||
copyBtn.title = 'Copy';
|
||
copyBtn.innerHTML = COPY_ICON;
|
||
copyBtn.addEventListener('click', () => {
|
||
const msg = bar.closest('.message');
|
||
if (!msg) {return;}
|
||
const text = getMessageText(msg);
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
copyBtn.innerHTML = CHECK_ICON;
|
||
copyBtn.classList.add('copied');
|
||
setTimeout(() => {
|
||
copyBtn.innerHTML = COPY_ICON;
|
||
copyBtn.classList.remove('copied');
|
||
}, 1500);
|
||
});
|
||
});
|
||
bar.appendChild(copyBtn);
|
||
|
||
// Edit button — user messages only
|
||
if (role === 'user') {
|
||
const editBtn = document.createElement('button');
|
||
editBtn.className = 'msg-action-btn';
|
||
editBtn.title = 'Edit';
|
||
editBtn.innerHTML = EDIT_ICON;
|
||
editBtn.addEventListener('click', () => {
|
||
const msg = bar.closest('.message');
|
||
if (!msg) {return;}
|
||
const text = getMessageText(msg);
|
||
const input = _elements.input;
|
||
if (input) {
|
||
input.value = text;
|
||
input.focus();
|
||
// Trigger auto-resize
|
||
input.style.height = 'auto';
|
||
input.style.height = Math.min(input.scrollHeight, 150) + 'px';
|
||
}
|
||
});
|
||
bar.appendChild(editBtn);
|
||
}
|
||
|
||
return bar;
|
||
}
|
||
|
||
// ── Search Mode ─────────────────────────────────────────────
|
||
|
||
function setSearchMode(active) {
|
||
_searchMode = active;
|
||
const btn = _elements.searchBtn;
|
||
const input = _elements.input;
|
||
if (!btn || !input) {return;}
|
||
|
||
if (active) {
|
||
btn.classList.add('active');
|
||
input.placeholder = 'What do you want to search for?';
|
||
} else {
|
||
btn.classList.remove('active');
|
||
input.placeholder = 'Type a message...';
|
||
}
|
||
input.focus();
|
||
}
|
||
|
||
// ── Slash Popup ─────────────────────────────────────────────
|
||
|
||
function getFilteredCommands(text) {
|
||
const prefix = text.toLowerCase();
|
||
return SLASH_COMMANDS.filter(c => c.name.startsWith(prefix));
|
||
}
|
||
|
||
function showSlashPopup(filtered) {
|
||
const popup = _elements.slashPopup;
|
||
if (!popup) {return;}
|
||
|
||
popup.innerHTML = '';
|
||
if (filtered.length === 0) {
|
||
hideSlashPopup();
|
||
return;
|
||
}
|
||
|
||
for (let i = 0; i < filtered.length; i++) {
|
||
const item = document.createElement('div');
|
||
item.className = 'slash-popup-item' + (i === _slashPopupIndex ? ' selected' : '');
|
||
item.innerHTML = `<span class="cmd-name">${escapeHtml(filtered[i].name)}</span><span class="cmd-desc">${escapeHtml(filtered[i].desc)}</span>`;
|
||
item.addEventListener('click', () => {
|
||
selectSlashCommand(filtered[i].name);
|
||
});
|
||
// Touch-friendly: handle pointerenter for hover on mobile
|
||
item.addEventListener('pointerenter', () => {
|
||
_slashPopupIndex = i;
|
||
updatePopupSelection(filtered);
|
||
});
|
||
popup.appendChild(item);
|
||
}
|
||
popup.classList.remove('hidden');
|
||
}
|
||
|
||
function hideSlashPopup() {
|
||
const popup = _elements.slashPopup;
|
||
if (popup) {popup.classList.add('hidden');}
|
||
_slashPopupIndex = -1;
|
||
}
|
||
|
||
function updatePopupSelection(_filtered) {
|
||
const popup = _elements.slashPopup;
|
||
if (!popup) {return;}
|
||
const items = popup.querySelectorAll('.slash-popup-item');
|
||
items.forEach((el, i) => {
|
||
el.classList.toggle('selected', i === _slashPopupIndex);
|
||
});
|
||
}
|
||
|
||
function selectSlashCommand(name) {
|
||
const input = _elements.input;
|
||
if (!input) {return;}
|
||
input.value = name;
|
||
hideSlashPopup();
|
||
input.focus();
|
||
}
|
||
|
||
function handleSlashPopupInput() {
|
||
const input = _elements.input;
|
||
if (!input) {return;}
|
||
const text = input.value;
|
||
|
||
// Show popup only when text starts with / and is at most a single word (the command itself)
|
||
if (text.startsWith('/') && !text.includes(' ')) {
|
||
const filtered = getFilteredCommands(text);
|
||
// Clamp selection index
|
||
if (_slashPopupIndex >= filtered.length) {_slashPopupIndex = filtered.length - 1;}
|
||
showSlashPopup(filtered);
|
||
} else {
|
||
hideSlashPopup();
|
||
}
|
||
}
|
||
|
||
// ── Slash Command Handlers ──────────────────────────────────
|
||
|
||
function parseSlashCommand(text) {
|
||
const trimmed = text.trim();
|
||
if (!trimmed.startsWith('/')) {return null;}
|
||
|
||
const parts = trimmed.split(/\s+/);
|
||
const cmd = parts[0].toLowerCase();
|
||
const args = parts.slice(1).join(' ');
|
||
|
||
switch (cmd) {
|
||
case '/help': return { type: 'help' };
|
||
case '/reset': return { type: 'reset' };
|
||
case '/compact': return { type: 'compact' };
|
||
case '/usage': return { type: 'usage' };
|
||
case '/status': return { type: 'status' };
|
||
case '/model': return { type: 'model', args };
|
||
default: return null;
|
||
}
|
||
}
|
||
|
||
function showSystemMessage(content) {
|
||
_elements.messages.appendChild(createMessageEl('system', content));
|
||
scrollToBottom();
|
||
}
|
||
|
||
async function handleSlashCommand(cmd, client) {
|
||
switch (cmd.type) {
|
||
case 'help': {
|
||
const lines = [
|
||
'**Available Commands**',
|
||
'',
|
||
'| Command | Description |',
|
||
'|---------|-------------|',
|
||
'| `/help` | Show this help |',
|
||
'| `/reset` | Reset the current session |',
|
||
'| `/compact` | Ask the agent to compact context |',
|
||
'| `/usage` | Show token usage stats |',
|
||
'| `/status` | Show system health |',
|
||
'| `/model` | Show current model info |',
|
||
'',
|
||
'Type `/` to see autocomplete suggestions.',
|
||
];
|
||
showSystemMessage(lines.join('\n'));
|
||
return true;
|
||
}
|
||
|
||
case 'reset': {
|
||
try {
|
||
// Send reset command via metadata
|
||
const stream = client.stream('agent.send', {
|
||
message: '/reset',
|
||
metadata: { isCommand: true, command: 'reset' },
|
||
});
|
||
await stream.result;
|
||
_elements.messages.innerHTML = '';
|
||
showSystemMessage('Session reset.');
|
||
} catch (err) {
|
||
showSystemMessage(`Failed to reset: ${err.message}`);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
case 'compact': {
|
||
// Send as a regular message — the agent will interpret the request
|
||
showSystemMessage('Requesting context compaction...');
|
||
return false; // Let it pass through as a normal message
|
||
}
|
||
|
||
case 'usage': {
|
||
try {
|
||
const result = await client.call('system.tokenUsage');
|
||
const sessions = result.sessions ?? [];
|
||
if (sessions.length === 0) {
|
||
showSystemMessage('No usage data available.');
|
||
} else {
|
||
const lines = ['**Token Usage**', ''];
|
||
let totalIn = 0, totalOut = 0, totalCalls = 0;
|
||
for (const s of sessions) {
|
||
totalIn += s.total?.inputTokens ?? 0;
|
||
totalOut += s.total?.outputTokens ?? 0;
|
||
totalCalls += s.total?.calls ?? 0;
|
||
}
|
||
lines.push(`**Input:** ${totalIn.toLocaleString()} tokens`);
|
||
lines.push(`**Output:** ${totalOut.toLocaleString()} tokens`);
|
||
lines.push(`**API Calls:** ${totalCalls}`);
|
||
if (sessions.length > 1) {
|
||
lines.push(`**Sessions:** ${sessions.length}`);
|
||
}
|
||
showSystemMessage(lines.join('\n'));
|
||
}
|
||
} catch (err) {
|
||
showSystemMessage(`Failed to fetch usage: ${err.message}`);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
case 'status': {
|
||
try {
|
||
const result = await client.call('system.health');
|
||
const lines = [
|
||
'**System Status**',
|
||
'',
|
||
`**Uptime:** ${result.uptime ?? 'unknown'}`,
|
||
`**Status:** ${result.status ?? 'unknown'}`,
|
||
];
|
||
if (result.channels) {
|
||
lines.push('', '**Channels:**');
|
||
for (const ch of result.channels) {
|
||
const dot = ch.status === 'connected' ? '\\*' : '-';
|
||
lines.push(` ${dot} ${ch.name}: ${ch.status}`);
|
||
}
|
||
}
|
||
if (result.model) {
|
||
lines.push('', `**Model:** ${result.model}`);
|
||
}
|
||
showSystemMessage(lines.join('\n'));
|
||
} catch (err) {
|
||
showSystemMessage(`Failed to fetch status: ${err.message}`);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
case 'model': {
|
||
try {
|
||
const result = await client.call('system.health');
|
||
const model = result.model ?? result.config?.model ?? 'unknown';
|
||
showSystemMessage(`**Current Model:** ${model}`);
|
||
} catch (err) {
|
||
showSystemMessage(`Failed to fetch model info: ${err.message}`);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ── Session Management ──────────────────────────────────────
|
||
|
||
async function loadSessions(client) {
|
||
const select = _elements.sessionSelect;
|
||
if (!select) {return;}
|
||
|
||
try {
|
||
const result = await client.call('sessions.list');
|
||
const sessions = result.sessions ?? [];
|
||
|
||
// Preserve current selection
|
||
const current = _currentSession;
|
||
select.innerHTML = '';
|
||
|
||
if (sessions.length === 0) {
|
||
const opt = document.createElement('option');
|
||
opt.value = '';
|
||
opt.textContent = 'No sessions';
|
||
select.appendChild(opt);
|
||
} else {
|
||
for (const s of sessions) {
|
||
const opt = document.createElement('option');
|
||
opt.value = s.id;
|
||
opt.textContent = `${s.id} (${s.messageCount} msgs)`;
|
||
if (s.id === current) {opt.selected = true;}
|
||
select.appendChild(opt);
|
||
}
|
||
}
|
||
|
||
// Update current session
|
||
_currentSession = select.value || null;
|
||
} catch {
|
||
// Ignore — sessions may not be available
|
||
}
|
||
}
|
||
|
||
async function loadHistory(client) {
|
||
const msgs = _elements.messages;
|
||
if (!msgs || !_currentSession) {return;}
|
||
|
||
msgs.innerHTML = '';
|
||
|
||
try {
|
||
const result = await client.call('sessions.history', { sessionId: _currentSession });
|
||
const messages = result.messages ?? [];
|
||
|
||
for (const msg of messages) {
|
||
const role = msg.role ?? 'assistant';
|
||
const content = msg.content ?? msg.text ?? '';
|
||
msgs.appendChild(createMessageEl(role, content));
|
||
}
|
||
|
||
scrollToBottom();
|
||
} catch {
|
||
msgs.innerHTML = '<div class="empty-state">Could not load history</div>';
|
||
}
|
||
}
|
||
|
||
// ── Send Message ────────────────────────────────────────────
|
||
|
||
async function sendMessage(client, overrideText) {
|
||
const input = _elements.input;
|
||
const rawText = (overrideText ?? input?.value ?? '').trim();
|
||
const hasText = Boolean(rawText);
|
||
const hasAttachments = Boolean(_pendingAttachments.length > 0);
|
||
if ((!hasText && !hasAttachments) || _sending) {return;}
|
||
|
||
// Check for slash commands first (text only)
|
||
if (hasText) {
|
||
const cmd = parseSlashCommand(rawText);
|
||
if (cmd) {
|
||
if (!overrideText) {input.value = '';}
|
||
hideSlashPopup();
|
||
const handled = await handleSlashCommand(cmd, client);
|
||
if (handled) {return;}
|
||
// If not fully handled (e.g. /compact), fall through to send as message
|
||
}
|
||
}
|
||
|
||
_sending = true;
|
||
_elements.sendBtn.disabled = true;
|
||
if (!overrideText && input) {input.value = '';}
|
||
|
||
// Apply search mode prefix
|
||
let messageText = rawText;
|
||
if (_searchMode && hasText && !rawText.startsWith('/')) {
|
||
messageText = `Search the web for: ${rawText}`;
|
||
setSearchMode(false);
|
||
}
|
||
|
||
const userDisplay = hasText
|
||
? rawText
|
||
: `Sent ${_pendingAttachments.length} attachment(s)`;
|
||
_elements.messages.appendChild(createMessageEl('user', userDisplay));
|
||
scrollToBottom();
|
||
|
||
// Create placeholder for assistant response
|
||
const placeholder = document.createElement('div');
|
||
placeholder.className = 'message assistant streaming-cursor';
|
||
placeholder.innerHTML = '<span class="text-muted">Thinking...</span>';
|
||
_elements.messages.appendChild(placeholder);
|
||
scrollToBottom();
|
||
|
||
try {
|
||
const stream = client.stream('agent.send', { message: messageText, attachments: _pendingAttachments });
|
||
|
||
stream.on('tool_start', (data) => {
|
||
const el = createToolEventEl('tool_start', data);
|
||
_elements.messages.insertBefore(el, placeholder);
|
||
scrollToBottom();
|
||
});
|
||
|
||
stream.on('tool_end', (data) => {
|
||
// Replace the last tool_start spinner with completion marker
|
||
const events = _elements.messages.querySelectorAll('.tool-event-group');
|
||
const last = events[events.length - 1];
|
||
if (last) {
|
||
const header = last.querySelector('.tool-event-header');
|
||
if (header && data.tool) {
|
||
const icon = data.result?.success !== false ? '✓' : '✗';
|
||
const cls = data.result?.success !== false ? 'status-ok' : 'status-error';
|
||
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
|
||
}
|
||
// Add result body
|
||
const body = last.querySelector('.tool-event-body');
|
||
if (body && data.result) {
|
||
body.textContent = data.result.output || data.result.error || '(no output)';
|
||
}
|
||
}
|
||
scrollToBottom();
|
||
});
|
||
|
||
const done = await stream.result;
|
||
// Replace placeholder with actual response
|
||
placeholder.classList.remove('streaming-cursor');
|
||
const content = done?.content ?? done?.text ?? '(no response)';
|
||
placeholder.innerHTML = renderSafeMarkdown(content);
|
||
placeholder.appendChild(createMessageActions('assistant'));
|
||
setTimeout(highlightCode, 0);
|
||
} catch (err) {
|
||
placeholder.classList.remove('streaming-cursor');
|
||
placeholder.className = 'message error';
|
||
placeholder.textContent = `Error: ${err.message}`;
|
||
} finally {
|
||
_sending = false;
|
||
if (_elements.sendBtn) {_elements.sendBtn.disabled = false;}
|
||
clearPendingAttachments();
|
||
scrollToBottom();
|
||
}
|
||
}
|
||
|
||
// ── Search SVG Icon ─────────────────────────────────────────
|
||
|
||
const SEARCH_ICON = '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8.5 3a5.5 5.5 0 0 1 4.38 8.82l4.15 4.15a.75.75 0 0 1-1.06 1.06l-4.15-4.15A5.5 5.5 0 1 1 8.5 3zm0 1.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" fill="currentColor"/></svg>';
|
||
const ATTACH_ICON = '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7.5 4.75A3.75 3.75 0 0 1 11.25 1h.5A3.25 3.25 0 0 1 15 4.25V12a4.5 4.5 0 0 1-9 0V6.75a2.75 2.75 0 0 1 5.5 0V12a1.25 1.25 0 0 1-2.5 0V6.75a.75.75 0 0 0-1.5 0V12a2.75 2.75 0 0 0 5.5 0V6.75A4.25 4.25 0 0 0 5 6.75V12a6 6 0 0 0 12 0V4.25A4.75 4.75 0 0 0 11.75-.5h-.5A5.25 5.25 0 0 0 6 4.75v8a.75.75 0 0 0 1.5 0z" fill="currentColor"/></svg>';
|
||
const COPY_ICON = '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" fill="currentColor"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" fill="currentColor"/></svg>';
|
||
const CHECK_ICON = '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" fill="currentColor"/></svg>';
|
||
const EDIT_ICON = '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm1.414 1.06a.25.25 0 0 0-.354 0L3.463 11.1l-.47 1.64 1.64-.47 8.61-8.61a.25.25 0 0 0 0-.354Z" fill="currentColor"/></svg>';
|
||
|
||
// ── Page Export ──────────────────────────────────────────────
|
||
|
||
export const ChatPage = {
|
||
async render(el, client) {
|
||
el.innerHTML = `
|
||
<div class="chat-layout">
|
||
<div class="chat-header">
|
||
<select id="chat-session-select"></select>
|
||
<button id="chat-new-session" class="btn btn-secondary">+ New</button>
|
||
<button id="chat-load-history" class="btn btn-secondary">History</button>
|
||
</div>
|
||
<div class="chat-messages" id="chat-messages"></div>
|
||
<div class="chat-actions">
|
||
<button id="chat-search" class="btn-action" title="Search the web">
|
||
${SEARCH_ICON}
|
||
<span class="btn-action-label">Search</span>
|
||
</button>
|
||
<button id="chat-attach" class="btn-action" title="Attach image">
|
||
${ATTACH_ICON}
|
||
<span class="btn-action-label">Attach</span>
|
||
</button>
|
||
<input id="chat-file" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple class="hidden" />
|
||
<div id="chat-attachments" class="chat-attachments hidden"></div>
|
||
</div>
|
||
<div class="chat-input-wrapper">
|
||
<div id="slash-popup" class="slash-popup hidden"></div>
|
||
<div class="chat-input">
|
||
<textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
|
||
<button id="chat-send" class="btn btn-primary">Send</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
_elements = {
|
||
sessionSelect: el.querySelector('#chat-session-select'),
|
||
messages: el.querySelector('#chat-messages'),
|
||
input: el.querySelector('#chat-input'),
|
||
sendBtn: el.querySelector('#chat-send'),
|
||
searchBtn: el.querySelector('#chat-search'),
|
||
attachBtn: el.querySelector('#chat-attach'),
|
||
fileInput: el.querySelector('#chat-file'),
|
||
attachments: el.querySelector('#chat-attachments'),
|
||
slashPopup: el.querySelector('#slash-popup'),
|
||
};
|
||
|
||
// Load sessions into dropdown
|
||
await loadSessions(client);
|
||
|
||
// Event: session change
|
||
_elements.sessionSelect.addEventListener('change', () => {
|
||
_currentSession = _elements.sessionSelect.value || null;
|
||
});
|
||
|
||
// Event: new session
|
||
el.querySelector('#chat-new-session').addEventListener('click', async () => {
|
||
try {
|
||
const result = await client.call('sessions.create');
|
||
_currentSession = result.sessionId;
|
||
await loadSessions(client);
|
||
_elements.messages.innerHTML = '';
|
||
} catch (err) {
|
||
_elements.messages.innerHTML = `<div class="empty-state">Failed to create session: ${err.message}</div>`;
|
||
}
|
||
});
|
||
|
||
// Event: load history
|
||
el.querySelector('#chat-load-history').addEventListener('click', () => {
|
||
loadHistory(client);
|
||
});
|
||
|
||
// Event: search button toggle
|
||
_elements.searchBtn.addEventListener('click', () => {
|
||
setSearchMode(!_searchMode);
|
||
});
|
||
|
||
// Event: attach button
|
||
_elements.attachBtn.addEventListener('click', () => {
|
||
if (_elements.fileInput) {_elements.fileInput.click();}
|
||
});
|
||
|
||
// Event: file input change
|
||
_elements.fileInput.addEventListener('change', async () => {
|
||
const files = Array.from(_elements.fileInput.files ?? []);
|
||
if (files.length === 0) {return;}
|
||
|
||
const MAX_BYTES = 5 * 1024 * 1024;
|
||
for (const file of files) {
|
||
if (!isSupportedImageMime(file.type)) {
|
||
showSystemMessage(`Unsupported file type: ${file.type || file.name}`);
|
||
continue;
|
||
}
|
||
if (file.size > MAX_BYTES) {
|
||
showSystemMessage(`File too large (max 5MB): ${file.name}`);
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
const data = await readFileAsBase64(file);
|
||
_pendingAttachments.push({ mimeType: file.type, data, filename: file.name });
|
||
} catch (err) {
|
||
showSystemMessage(`Failed to attach ${file.name}: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
renderPendingAttachments();
|
||
});
|
||
|
||
// Event: send message
|
||
_elements.sendBtn.addEventListener('click', () => sendMessage(client));
|
||
|
||
// Event: keyboard in textarea
|
||
_elements.input.addEventListener('keydown', (e) => {
|
||
const popup = _elements.slashPopup;
|
||
const isPopupVisible = popup && !popup.classList.contains('hidden');
|
||
|
||
// Handle slash popup navigation
|
||
if (isPopupVisible) {
|
||
const text = _elements.input.value;
|
||
const filtered = getFilteredCommands(text);
|
||
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
_slashPopupIndex = Math.min(_slashPopupIndex + 1, filtered.length - 1);
|
||
updatePopupSelection(filtered);
|
||
return;
|
||
}
|
||
if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
_slashPopupIndex = Math.max(_slashPopupIndex - 1, 0);
|
||
updatePopupSelection(filtered);
|
||
return;
|
||
}
|
||
if ((e.key === 'Enter' || e.key === 'Tab') && _slashPopupIndex >= 0 && _slashPopupIndex < filtered.length) {
|
||
e.preventDefault();
|
||
selectSlashCommand(filtered[_slashPopupIndex].name);
|
||
return;
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
hideSlashPopup();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Enter to send (Shift+Enter for newline)
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
hideSlashPopup();
|
||
sendMessage(client);
|
||
}
|
||
});
|
||
|
||
// Event: input changes for slash popup + auto-resize
|
||
_elements.input.addEventListener('input', () => {
|
||
// Auto-resize textarea
|
||
const ta = _elements.input;
|
||
ta.style.height = 'auto';
|
||
ta.style.height = Math.min(ta.scrollHeight, 150) + 'px';
|
||
|
||
// Slash command popup
|
||
handleSlashPopupInput();
|
||
});
|
||
|
||
// Dismiss slash popup on outside click
|
||
el.addEventListener('click', (e) => {
|
||
if (!e.target.closest('.chat-input-wrapper')) {
|
||
hideSlashPopup();
|
||
}
|
||
});
|
||
|
||
// If there's a current session, show welcome
|
||
if (!_currentSession) {
|
||
_elements.messages.innerHTML = '<div class="empty-state">Select a session or create a new one to start chatting</div>';
|
||
}
|
||
},
|
||
|
||
teardown() {
|
||
_currentSession = null;
|
||
_sending = false;
|
||
_searchMode = false;
|
||
_slashPopupIndex = -1;
|
||
_elements = {};
|
||
_pendingAttachments = [];
|
||
},
|
||
};
|