Files
yacy_search_server/htroot/yacychat.html

1914 lines
66 KiB
HTML

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>YaCy '#[clientname]#': Chat</title>
#%env/templates/metas.template%#
<link rel="stylesheet" href="js/styles/a11y-dark.min.css" type="text/css" />
<style type="text/css">
/* 1. Global Container */
.chat-panel {
width: 100%;
color: #333333;
padding: 0 0 32px 0;
}
/* 2. Layout Structure */
.chat-flow {
display: flex;
flex-direction: column;
gap: 15px;
}
.chat-messages {
display: flex;
flex-direction: column;
gap: 10px;
}
/* 3. The Message Blocks - Structural Look */
.chat-turn {
position: relative;
border: 1px solid #e0e0e0;
border-radius: 0px;
padding-top: 2px;
padding-bottom: 12px;
padding-left: 20px;
padding-right: 20px;
margin: 0;
box-shadow: 2px 2px 0px rgba(0,0,0,0.1);
transition: all 0.2s;
}
.chat-turn:hover {
box-shadow: 3px 3px 0px rgba(0,0,0,0.15);
}
/* 4. Labels (User/Assistant Headers) - Headline Font Consistency */
.chat-turn legend {
font-family: inherit;
padding: 4px 10px;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
border: 1px solid transparent;
margin-bottom: 8px; /* Increased spacing */
}
/* --- USER: Lighter Grey Input --- */
.chat-turn.user {
background-color: #ECF1F8;
border-left-width: 5px;
border-left-style: solid;
border-color: #d1d9e6;
color: #2c3e50;
}
.chat-turn.user legend {
background: #5092CF;
color: #ffffff;
border: 1px solid #5092CF;
}
/* --- ASSISTANT: Darker Grey Output --- */
.chat-turn.assistant {
background-color: #DEE7F3;
border-left-width: 5px;
border-left-style: solid;
border-color: #bbccdd;
color: #000;
}
.chat-turn.assistant legend {
background: #2c3e50;
color: #ffffff;
border: 1px solid #2c3e50;
}
/* --- SYSTEM: Log Entry Style --- */
.chat-turn.system {
background: #fff3cd;
border: 1px solid #ffeeba;
border-left: 5px solid #ffc107;
color: #856404;
font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
font-size: 1.2rem;
}
/* 5. Typography - All Chat Content is MONOSPACE */
.chat-body {
line-height: 1.6;
font-size: 1.2rem; /* Larger font size */
font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
}
.chat-body.plain-text {
white-space: pre-wrap;
font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
}
.chat-body.markdown {
white-space: normal;
}
.chat-body.markdown,
.chat-body.markdown * {
font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace !important; /* Force markdown output and descendants to stay monospace, FTW! */
}
.chat-body.markdown.markdown-body {
max-width: 100%;
padding: 0;
}
.chat-body.markdown h1,
.chat-body.markdown h2,
.chat-body.markdown h3,
.chat-body.markdown h4,
.chat-body.markdown h5,
.chat-body.markdown h6,
.chat-body.markdown p,
.chat-body.markdown li,
.chat-body.markdown pre,
.chat-body.markdown code {
font-size: 1.2rem;
line-height: 1.6;
}
.chat-body pre {
background-color: #2c3e50;
border: 1px solid #dfe2e5;
border-radius: 4px;
padding: 2px;
overflow-x: auto;
font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
white-space: pre;
}
.chat-body code {
font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
font-size: 0.95rem;
background-color: rgba(27,31,35,0.05);
padding: 2px 4px;
border-radius: 4px;
}
/* 6. Composer Row (inside User fieldset) */
.composer {
display: flex;
gap: 10px;
align-items: stretch;
}
.chat-turn.user:focus-within {
background-color: #ECF1F8;
border-left-width: 5px;
border-left-style: solid;
border-color: #d1d9e6;
color: #2c3e50;
}
.composer-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.composer textarea {
flex: 0 0 auto;
resize: none;
border: none;
background: transparent;
padding: 0;
margin: 0;
outline: none;
overflow-y: hidden;
}
.attachment-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.95rem;
color: #2c3e50;
position: relative;
}
.attachment-row.has-attachment .attachment-clear {
display: inline-flex;
}
.attachment-row.has-attachment .attachment-button {
display: none;
}
.attachment-row.has-attachment .search-button {
display: none;
}
.attachment-row.search-mode .attachment-button,
.attachment-row.search-mode .search-button {
display: none;
}
.attachment-row.search-mode .attachment-clear {
display: inline-flex;
}
.attachment-row.loading .attachment-filename {
font-style: italic;
color: #6b7a90;
}
.attachment-button,
.search-button,
.copy-button,
.user-trim-button {
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: #d1d9e6;
color: #2c3e50;
font-weight: 800;
font-size: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.attachment-button:hover,
.search-button:hover,
.copy-button:hover,
.user-trim-button:hover {
background: #c3cbd9;
}
.attachment-filename {
font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attachment-clear {
display: none;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: #d1d9e6;
color: #2c3e50;
font-weight: 800;
font-size: 1.25rem;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.attachment-clear:hover {
background: #c3cbd9;
}
.copy-button,
.user-trim-button {
position: absolute;
top: -6px;
right: 8px;
}
.user-delete-button {
position: absolute;
top: -6px;
right: 36px;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: #d1d9e6;
color: #2c3e50;
font-weight: 800;
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.user-delete-button:hover {
background: #c3cbd9;
}
.hidden-file-input {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.attachment-chip {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 6px 10px;
background: #d1d9e6;
color: #2c3e50;
border-radius: 4px;
border: 1px solid #c3cbd9;
font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.attachment-chip:hover,
.attachment-chip:focus {
background: #c3cbd9;
outline: none;
}
.attachment-chip .glyphicon {
font-size: 1.05rem;
}
.attachment-chip .attachment-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 260px;
}
.attachment-preview {
position: absolute;
z-index: 30;
min-width: 280px;
max-width: 420px;
background: #ffffff;
color: #2c3e50;
border: 1px solid #c3cbd9;
box-shadow: 3px 3px 0px rgba(0,0,0,0.15);
border-radius: 6px;
padding: 10px 12px;
top: calc(100% + 6px);
left: 0;
}
.attachment-preview.above {
top: auto;
bottom: calc(100% + 6px);
}
.attachment-preview h5 {
margin: 0 0 6px 0;
font-size: 1rem;
font-weight: 700;
}
.attachment-preview .meta {
font-size: 0.9rem;
margin-bottom: 8px;
color: #40536a;
}
.attachment-preview .preview-body {
max-height: 240px;
overflow: auto;
background: #f5f7fb;
border: 1px solid #e0e6f0;
padding: 8px;
border-radius: 4px;
font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
font-size: 1rem;
white-space: pre-wrap;
}
.attachment-preview .preview-image {
max-width: 100%;
max-height: 220px;
display: block;
margin: 0 auto;
border: 1px solid #e0e6f0;
border-radius: 4px;
background: #fff;
}
.attachment-preview .actions {
display: flex;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.attachment-preview .preview-action {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: 1px solid #c3cbd9;
background: #d1d9e6;
color: #2c3e50;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.15s, border-color 0.15s;
}
.attachment-preview .preview-action:hover {
background: #c3cbd9;
}
.attachment-preview .preview-note {
margin-top: 6px;
font-size: 0.85rem;
color: #6b7a90;
}
.attachment-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.65);
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.attachment-modal {
background: #ffffff;
color: #2c3e50;
max-width: 800px;
width: 100%;
max-height: 90vh;
border-radius: 8px;
box-shadow: 4px 4px 0px rgba(0,0,0,0.2);
padding: 16px;
overflow: hidden;
position: relative;
}
.attachment-modal .modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.attachment-modal .modal-header h4 {
margin: 0;
font-size: 1.1rem;
}
.attachment-modal .modal-body {
max-height: 70vh;
overflow: auto;
border: 1px solid #e0e6f0;
border-radius: 4px;
padding: 10px;
background: #f5f7fb;
}
.attachment-modal .modal-body pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.attachment-modal .close-modal {
border: none;
background: #d1d9e6;
color: #2c3e50;
border-radius: 4px;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
/* 7. The Button - Industrial Action */
.composer .btn {
align-self: stretch;
padding: 0 24px;
border: 2px solid #555;
background: #333;
color: #fff;
font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
font-weight: bold;
text-transform: uppercase;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
}
.composer .btn:hover {
background: #0056b3;
border-color: #0056b3;
}
.composer .btn:active {
background: #004494;
}
@media (max-width: 900px) {
.composer {
flex-direction: column;
}
.composer .btn {
width: 100%;
border: 2px solid #555;
}
}
.clear-chat-row {
display: flex;
visibility: hidden;
pointer-events: none;
margin-top: 12px;
gap: 10px;
align-items: center;
}
.clear-chat-row .btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
</style>
</head>
<body id="IndexControl">
#(topmenu)#
#%env/templates/embeddedheader.template%#
::
#%env/templates/simpleheader.template%#
::
#%env/templates/header.template%#
#%env/templates/submenuAI.template%#
#(/topmenu)#
<h2>YaCy Chat</h2>
<p>Chat with your configured LLM using the local YaCy settings. Requests are sent to the same host that served this page.</p>
<div class="chat-panel">
<div class="chat-flow">
<div class="chat-messages" id="chatMessages" aria-live="polite"></div>
<form id="chatForm">
<fieldset class="chat-turn user">
<legend>User</legend>
<div class="composer">
<div class="composer-main" id="composerMain">
<textarea class="chat-body" id="userInput" rows="3" placeholder="Ask me anything..." required="required"></textarea>
<div class="attachment-row" id="attachmentRow">
<button type="button" class="search-button" id="searchButton" aria-label="Search">
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
</button>
<button type="button" class="attachment-button" id="addFileButton" aria-label="Attach a file">
<span class="glyphicon glyphicon-paperclip" aria-hidden="true"></span>
</button>
<button type="button" class="attachment-clear" id="clearFileButton" aria-label="Remove attached file">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
</button>
<span class="attachment-filename" id="attachmentFilename">Attach PNG/JPG or text (.txt/.md/.tex)</span>
<input type="file" id="fileInput" class="hidden-file-input" accept="image/png,image/jpeg,text/plain,text/markdown,text/x-tex,application/x-tex,application/x-latex,text/*,.png,.jpg,.jpeg,.txt,.md,.markdown,.tex"/>
</div>
</div>
<input type="submit" class="btn btn-primary" id="sendButton" value="Send"/>
</div>
</fieldset>
</form>
<div class="clear-chat-row" id="clearChatRow">
<button type="button" class="btn btn-inverse label-warning" id="clearChatButton" aria-label="Clear chat">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
Clear Chat
</button>
<button type="button" class="btn btn-inverse label-success" id="downloadChatButton" aria-label="Download chat">
<span class="glyphicon glyphicon-download" aria-hidden="true"></span>
Download Chat
</button>
<button type="button" class="btn btn-inverse label-primary" id="uploadChatButton" aria-label="Upload chat">
<span class="glyphicon glyphicon-upload" aria-hidden="true"></span>
Upload Chat
</button>
<input type="file" id="uploadChatInput" class="hidden-file-input" accept="application/json"/>
<button type="button" class="btn btn-inverse label-info" id="toggleSystemButton" aria-label="Show system prompt">
<span class="glyphicon glyphicon-star" aria-hidden="true"></span>
<span class="toggle-label">Show System</span>
</button>
</div>
</div>
</div>
<script src="js/highlight.min.js"></script>
<script src="js/marked.umd.js"></script>
<script src="js/index.umd.min.js"></script>
<script type="text/javascript">
const SYSTEM_PROMPT = '#[system_prompt]#';
const defaultApiHost = '';
const STORAGE_KEY = 'yacychat_recent_pairs';
const state = {
messages: [],
config: {
apiHost: defaultApiHost,
model: 'chat',
systemPrompt: SYSTEM_PROMPT
},
busy: false,
attachment: null,
searchMode: false,
assistantSeen: false,
showSystem: false
};
const dom = {
messages: document.getElementById('chatMessages'),
form: document.getElementById('chatForm'),
input: document.getElementById('userInput'),
sendButton: document.getElementById('sendButton'),
fileInput: document.getElementById('fileInput'),
searchButton: document.getElementById('searchButton'),
addFileButton: document.getElementById('addFileButton'),
attachmentFilename: document.getElementById('attachmentFilename'),
clearFileButton: document.getElementById('clearFileButton'),
attachmentRow: document.getElementById('attachmentRow'),
clearChatRow: document.getElementById('clearChatRow'),
clearChatButton: document.getElementById('clearChatButton'),
downloadChatButton: document.getElementById('downloadChatButton'),
uploadChatButton: document.getElementById('uploadChatButton'),
uploadChatInput: document.getElementById('uploadChatInput'),
toggleSystemButton: document.getElementById('toggleSystemButton'),
composerMain: document.getElementById('composerMain')
};
const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg']);
const TEXT_MIME_TYPES = new Set(['text/plain', 'text/markdown', 'text/x-tex', 'application/x-tex', 'application/x-latex']);
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg']);
const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.markdown', '.tex']);
const ALLOWED_MIME_TYPES = new Set([...IMAGE_MIME_TYPES, ...TEXT_MIME_TYPES, 'text/*']);
const ALLOWED_EXTENSIONS = new Set([...IMAGE_EXTENSIONS, ...TEXT_EXTENSIONS]);
const FILE_ACCEPT_TYPES = [...ALLOWED_MIME_TYPES, ...ALLOWED_EXTENSIONS].join(',');
if (dom.fileInput) {
dom.fileInput.setAttribute('accept', FILE_ACCEPT_TYPES);
}
if (dom.attachmentFilename) {
dom.attachmentFilename.tabIndex = 0;
setupAttachmentPreviewTrigger(dom.attachmentFilename, () => state.attachment);
}
if (typeof hljs !== 'undefined') {
hljs.configure({ ignoreUnescapedHTML: true });
}
const languageAlias = {
js: 'javascript',
jsx: 'javascript',
ts: 'typescript',
tsx: 'typescript',
sh: 'bash',
shell: 'bash',
bash: 'bash',
csharp: 'csharp',
'c#': 'csharp',
'c++': 'cpp',
py: 'python',
rb: 'ruby',
rs: 'rust',
md: 'markdown',
yml: 'yaml'
};
const languagePromises = {};
function normalizeLanguage(lang) {
if (!lang || typeof lang !== 'string') return '';
const key = lang.trim().toLowerCase();
return languageAlias[key] || key;
}
function ensureLanguage(lang) {
if (typeof hljs === 'undefined') return Promise.resolve(false);
const normalized = normalizeLanguage(lang);
if (!normalized) return Promise.resolve(false);
if (hljs.getLanguage(normalized)) return Promise.resolve(true);
if (languagePromises[normalized]) return languagePromises[normalized];
const loadScript = path => new Promise(resolve => {
const script = document.createElement('script');
script.src = path;
script.async = true;
script.onload = () => resolve(!!hljs.getLanguage(normalized));
script.onerror = () => {
console.warn('Failed to load highlight.js language file:', path);
resolve(false);
};
document.head.appendChild(script);
});
const base = (window.location && window.location.origin) ? window.location.origin : '';
const minSrc = `${base}/js/languages/${normalized}.min.js`;
const fullSrc = `${base}/js/languages/${normalized}.js`;
languagePromises[normalized] = loadScript(minSrc).then(success => {
if (success) return true;
return loadScript(fullSrc);
}).then(success => success || !!hljs.getLanguage(normalized));
return languagePromises[normalized];
}
function rehighlightCode(container) {
if (!container || typeof hljs === 'undefined') return;
const codeBlocks = Array.from(container.querySelectorAll('pre code'));
if (!codeBlocks.length) return;
const tasks = codeBlocks
.map(block => Array.from(block.classList).find(cls => cls.startsWith('language-')))
.map(match => match ? normalizeLanguage(match.replace(/^language-/, '')) : '')
.filter(Boolean)
.map(lang => ensureLanguage(lang));
Promise.all(tasks).then(() => {
codeBlocks.forEach(block => {
try {
hljs.highlightElement(block);
} catch (err) {
// ignore highlight failures
}
});
});
}
const markdownSupport = (() => {
if (typeof marked === 'undefined') {
return { enabled: false };
}
try {
if (window.markedHighlight && typeof window.markedHighlight.markedHighlight === 'function' && typeof hljs !== 'undefined') {
marked.use(window.markedHighlight.markedHighlight({
langPrefix: 'hljs language-',
highlight: (code, lang) => {
const language = normalizeLanguage(lang);
ensureLanguage(language);
if (language && hljs.getLanguage(language)) {
return hljs.highlight(code, { language }).value;
}
// kick off lazy load; will rehighlight after render
return escapeHTML(code);
}
}));
}
} catch (err) {
console.warn('Failed to initialize syntax highlighting', err);
}
marked.setOptions({
gfm: true,
breaks: true,
smartLists: true,
mangle: false,
headerIds: false
});
return { enabled: true };
})();
function escapeHTML(value) {
const div = document.createElement('div');
div.textContent = value || '';
return div.innerHTML;
}
function sanitizeHTML(html) {
if (!html) return '';
const template = document.createElement('template');
template.innerHTML = html;
const blockedTags = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta']);
const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT, null);
const toRemove = [];
while (walker.nextNode()) {
const el = walker.currentNode;
if (!el || !el.tagName) continue;
const tag = el.tagName.toLowerCase();
if (blockedTags.has(tag)) {
toRemove.push(el);
continue;
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const value = attr.value || '';
if (name.startsWith('on')) {
el.removeAttribute(attr.name);
continue;
}
if ((name === 'href' || name === 'src') && /^\s*javascript:/i.test(value)) {
el.removeAttribute(attr.name);
}
}
}
toRemove.forEach(node => node.remove());
return template.innerHTML;
}
function renderMarkdown(content) {
const source = typeof content === 'string' ? content : '';
if (!markdownSupport.enabled || typeof marked === 'undefined') {
return escapeHTML(source);
}
try {
return sanitizeHTML(marked.parse(source));
} catch (err) {
console.warn('Markdown rendering failed', err);
return escapeHTML(source);
}
}
function applyMessageContent(target, role, text) {
if (!target) return;
if (role === 'assistant') {
target.classList.add('markdown', 'markdown-body');
target.classList.remove('plain-text');
target.innerHTML = renderMarkdown(text);
rehighlightCode(target);
} else {
target.classList.add('plain-text');
target.classList.remove('markdown');
target.textContent = text || '';
}
}
function scrollToBottom(smooth = true) {
window.requestAnimationFrame(() => {
const behavior = smooth ? 'smooth' : 'auto';
window.scrollTo({ top: document.documentElement.scrollHeight, behavior });
});
}
function formatTimestamp() {
const now = new Date();
const pad = (n, len = 2) => n.toString().padStart(len, '0');
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
}
function updateSystemToggleButton() {
if (!dom.toggleSystemButton) return;
const icon = dom.toggleSystemButton.querySelector('.glyphicon');
if (icon) {
icon.className = `glyphicon ${state.showSystem ? 'glyphicon-star-empty' : 'glyphicon-star'}`;
}
const label = state.showSystem ? 'Hide System' : 'Show System';
dom.toggleSystemButton.querySelector('.toggle-label').textContent = label;
}
function appendMessage(role, text, opts = {}) {
const { skipScroll = false, skipVisibilityUpdate = false, attachment = null, messageIndex = undefined } = opts;
const entry = document.createElement('fieldset');
entry.className = `chat-turn ${role}`;
if (role === 'assistant' || role === 'user') {
entry.style.position = 'relative';
}
const legend = document.createElement('legend');
if (role === 'assistant') {
legend.textContent = 'Assistant';
} else if (role === 'user') {
legend.textContent = 'User';
} else {
legend.textContent = 'System';
}
entry.appendChild(legend);
const body = document.createElement('div');
body.className = 'chat-body';
applyMessageContent(body, role, text);
entry.appendChild(body);
if (attachment && role === 'user') {
const attachmentContainer = document.createElement('div');
attachmentContainer.appendChild(createAttachmentChip(attachment));
entry.appendChild(attachmentContainer);
}
dom.messages.appendChild(entry);
if (typeof opts.messageIndex === 'number') {
entry.dataset.messageIndex = String(opts.messageIndex);
}
if (role === 'assistant') {
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'copy-button';
copyBtn.title = 'Copy answer';
copyBtn.innerHTML = '<span class="glyphicon glyphicon-copy" aria-hidden="true"></span>';
copyBtn.addEventListener('click', () => copyAssistant(body.textContent));
entry.appendChild(copyBtn);
}
if (role === 'user' && typeof messageIndex === 'number') {
const trimBtn = document.createElement('button');
trimBtn.type = 'button';
trimBtn.className = 'user-trim-button';
trimBtn.title = 'Reuse from here';
trimBtn.innerHTML = '<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>';
trimBtn.addEventListener('click', () => trimConversationFromIndex(messageIndex, body.textContent));
entry.appendChild(trimBtn);
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'user-delete-button';
deleteBtn.title = 'Delete this turn';
deleteBtn.innerHTML = '<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>';
deleteBtn.addEventListener('click', () => deleteConversationPair(messageIndex));
entry.appendChild(deleteBtn);
}
if (!skipScroll && !state.busy) {
scrollToBottom();
}
if (!skipVisibilityUpdate && role === 'assistant') {
state.assistantSeen = true;
updateClearChatVisibility();
}
return body;
}
function clearAttachment() {
state.attachment = null;
state.searchMode = false;
dom.fileInput.value = '';
dom.attachmentFilename.textContent = 'Attach PNG/JPG or text (.txt/.md/.tex)';
dom.attachmentRow.classList.remove('has-attachment');
dom.attachmentRow.classList.remove('search-mode');
closeAttachmentPopover();
}
function setComposerAttachment(attachment) {
state.searchMode = false;
dom.attachmentRow.classList.remove('search-mode');
state.attachment = attachment ? cloneAttachment(attachment) : null;
if (state.attachment) {
dom.attachmentFilename.textContent = `Attach File: ${state.attachment.name || 'Attachment'}`;
dom.attachmentRow.classList.add('has-attachment');
} else {
clearAttachment();
}
}
function setAttachmentLoading(isLoading) {
if (!dom.attachmentRow || !dom.attachmentFilename) return;
dom.attachmentRow.classList.toggle('loading', !!isLoading);
if (isLoading) {
dom.attachmentFilename.textContent = 'Loading attachment...';
} else if (!state.attachment) {
dom.attachmentFilename.textContent = 'Attach PNG/JPG or text (.txt/.md/.tex)';
}
}
async function copyAssistant(text) {
try {
await navigator.clipboard.writeText(text || '');
} catch (err) {
console.warn('Clipboard copy failed', err);
}
}
function trimConversationFromIndex(index, reuseText) {
if (typeof index !== 'number' || index < 0) return;
const targetMessage = state.messages[index];
state.messages = state.messages.slice(0, index);
state.assistantSeen = state.messages.some(m => m.role === 'assistant');
state.showSystem = false;
renderConversation();
persistConversation();
updateClearChatVisibility();
updateSystemToggleButton();
setComposerAttachment(targetMessage?.attachment || null);
dom.input.value = reuseText || '';
resizeComposer();
showComposer();
}
function deleteConversationPair(userIndex) {
if (typeof userIndex !== 'number' || userIndex < 0) return;
if (!state.messages[userIndex] || state.messages[userIndex].role !== 'user') return;
const nextMessage = state.messages[userIndex + 1];
const removeCount = nextMessage && nextMessage.role === 'assistant' ? 2 : 1;
state.messages.splice(userIndex, removeCount);
state.assistantSeen = state.messages.some(m => m.role === 'assistant');
renderConversation();
persistConversation();
updateClearChatVisibility();
updateSystemToggleButton();
showComposer();
}
function hideComposer() {
if (dom.form) {
dom.form.style.visibility = 'hidden';
dom.form.style.pointerEvents = 'none';
}
}
function showComposer() {
if (dom.form) {
dom.form.style.visibility = '';
dom.form.style.pointerEvents = '';
}
if (dom.input) {
try {
dom.input.focus({ preventScroll: true });
} catch (err) {
dom.input.focus();
}
}
}
function updateClearChatVisibility() {
if (!dom.clearChatRow) return;
dom.clearChatRow.style.visibility = state.assistantSeen ? 'visible' : 'hidden';
dom.clearChatRow.style.pointerEvents = state.assistantSeen ? 'auto' : 'none';
}
function ensureComposerVisible(evt) {
if (evt && evt.isTrusted === false) return;
scrollToBottom(true);
}
function clearChatHistory() {
dom.messages.innerHTML = '';
state.messages = [];
state.assistantSeen = false;
state.showSystem = false;
localStorage.removeItem(STORAGE_KEY);
closeAttachmentPopover();
closeAttachmentModal();
updateClearChatVisibility();
updateSystemToggleButton();
dom.input.value = '';
clearAttachment();
resizeComposer();
}
function downloadChat() {
const payload = buildRequestPayload();
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `yacychat-${formatTimestamp()}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function applyUploadedChat(payload) {
if (!payload || !Array.isArray(payload.messages)) {
appendMessage('system', 'Invalid chat file.');
return;
}
clearChatHistory();
if (payload.model) {
state.config.model = payload.model;
}
for (const msg of payload.messages) {
if (!msg?.role || msg.content === undefined) continue;
state.messages.push({ role: msg.role, content: msg.content });
}
renderConversation();
persistConversation();
}
async function handleUploadChatFile(event) {
const file = event.target.files && event.target.files[0];
dom.uploadChatInput.value = '';
if (!file) return;
try {
const text = await file.text();
const parsed = JSON.parse(text);
applyUploadedChat(parsed);
} catch (err) {
appendMessage('system', `Failed to load chat file: ${err.message}`);
}
}
function ensureSystemMessage() {
if (!state.messages.length || state.messages[0].role !== 'system') {
state.messages.unshift({ role: 'system', content: state.config.systemPrompt });
}
}
async function streamChat(userMessage, assistantNode, options = {}) {
const { onFirstToken, includeUserInPayload = true, pushUserToState = true } = options;
ensureSystemMessage();
const sanitizedMessages = state.messages.map(stripMessageForApi).filter(Boolean);
const sanitizedUserMessage = includeUserInPayload ? stripMessageForApi(userMessage) : null;
const payload = {
model: state.config.model,
messages: sanitizedUserMessage ? [...sanitizedMessages, sanitizedUserMessage] : sanitizedMessages,
stream: true
};
const headers = { 'Content-Type': 'application/json' };
const response = await fetch(state.config.apiHost.replace(/\/$/, '') + '/v1/chat/completions', {
method: 'POST',
headers,
body: JSON.stringify(payload)
});
if (!response.ok) {
if (response.status === 429) {
throw new Error('Rate limit reached: please wait and try again. The server is protecting itself from overload.');
}
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error('Streaming not supported by this browser.');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let assistantText = '';
let sawFirstDelta = false;
let focusShown = false;
let searchApplied = false;
if (typeof onFirstToken === 'function' && !focusShown) {
focusShown = true;
onFirstToken();
}
let autoScroll = true;
let pending = '';
const applySearchAttachment = parsed => {
if (searchApplied) return;
if (!parsed) return;
const searchName = parsed['search-filename'];
const searchBase64 = parsed['search-text-base64'];
if (!searchName || !searchBase64) return;
const dataUrl = `data:text/markdown;base64,${searchBase64}`;
const textContent = base64ToUtf8(searchBase64);
for (let i = state.messages.length - 1; i >= 0; i--) {
const msg = state.messages[i];
if (msg && msg.role === 'user' && msg.search) {
msg.attachment = {
kind: 'text',
name: searchName,
dataUrl,
mime: 'text/markdown',
textContent
};
delete msg.search;
searchApplied = true;
break;
}
}
};
const performAutoScroll = () => {
if (!autoScroll) return;
scrollToBottom(false);
const rect = assistantNode?.getBoundingClientRect();
if (rect && rect.top <= 110) {
autoScroll = false;
}
};
const processLine = line => {
const trimmed = (line || '').trim();
if (!trimmed) return false;
if (trimmed === 'data: [DONE]' || trimmed === '[DONE]') return true;
if (!trimmed.startsWith('data:')) return false;
const clean = trimmed.replace(/^data:\s*/i, '');
if (!clean) return false;
try {
const parsed = JSON.parse(clean);
applySearchAttachment(parsed);
const delta = parsed?.choices?.[0]?.delta?.content;
if (delta) {
if (!sawFirstDelta) {
sawFirstDelta = true;
applyMessageContent(assistantNode, 'assistant', '');
if (!focusShown && typeof onFirstToken === 'function') {
focusShown = true;
onFirstToken();
}
}
assistantText += delta;
applyMessageContent(assistantNode, 'assistant', assistantText);
performAutoScroll();
}
} catch (err) {
console.warn('Failed to parse line', trimmed, err);
}
return false;
};
const parseChunk = chunk => {
pending += chunk;
const lines = pending.split(/\r?\n/);
pending = lines.pop() || '';
for (const line of lines) {
if (processLine(line)) return true;
}
return false;
};
let done = false;
while (!done) {
const { value, done: readerDone } = await reader.read();
if (readerDone) break;
const chunk = decoder.decode(value, { stream: true });
if (!focusShown && chunk && chunk.trim() && typeof onFirstToken === 'function') {
focusShown = true;
onFirstToken();
}
done = parseChunk(chunk);
}
if (!done && pending.trim()) {
parseChunk('\n');
}
if (!focusShown && typeof onFirstToken === 'function') {
focusShown = true;
onFirstToken();
}
if (pushUserToState) {
state.messages.push(userMessage);
}
state.messages.push({ role: 'assistant', content: assistantText });
state.assistantSeen = true;
persistConversation();
updateClearChatVisibility();
updateSystemToggleButton();
renderConversation({ skipScroll: true });
}
function resizeComposer() {
const minHeight = 24;
dom.input.style.height = 'auto';
const measured = dom.input.scrollHeight || minHeight;
dom.input.style.height = `${Math.max(minHeight, measured)}px`;
}
function buildUserMessage(promptText) {
if (state.attachment) {
const parts = [{ type: 'text', text: promptText }];
if (state.attachment.kind === 'image' && state.attachment.dataUrl) {
parts.push({
type: 'image_url',
image_url: { url: state.attachment.dataUrl },
filename: state.attachment.name
});
} else if (state.attachment.kind === 'text' && state.attachment.dataUrl) {
parts.push({
type: 'image_url',
image_url: { url: state.attachment.dataUrl },
filename: state.attachment.name
});
}
return {
role: 'user',
content: parts,
search: !!state.searchMode
};
}
return { role: 'user', content: promptText, search: !!state.searchMode };
}
function stripMessageForApi(message) {
if (!message) return null;
const sanitized = { role: message.role, content: message.content };
if (message.search) sanitized.search = true;
return sanitized;
}
function buildRequestPayload() {
ensureSystemMessage();
return {
model: state.config.model,
messages: state.messages.map(stripMessageForApi).filter(Boolean),
stream: true
};
}
function messageText(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
const textPart = content.find(part => part?.type === 'text');
return textPart?.text || '';
}
if (content && typeof content === 'object' && typeof content.text === 'string') {
return content.text;
}
return '';
}
function renderConversation(options = {}) {
const { skipScroll = false } = options;
closeAttachmentPopover();
dom.messages.innerHTML = '';
state.assistantSeen = state.messages.some(m => m.role === 'assistant');
for (let i = 0; i < state.messages.length; i++) {
const msg = state.messages[i];
if (msg.role === 'system' && !state.showSystem) continue;
appendMessage(msg.role, messageText(msg.content), { skipScroll: true, skipVisibilityUpdate: true, messageIndex: i, attachment: msg.attachment });
}
updateClearChatVisibility();
updateSystemToggleButton();
if (!skipScroll && !state.busy) {
scrollToBottom();
}
}
function persistConversation() {
try {
const payload = {
model: state.config.model,
messages: state.messages
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
} catch (err) {
console.warn('Failed to persist conversation', err);
}
}
function hydrateConversation() {
let stored = null;
try {
stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
} catch (err) {
console.warn('Failed to parse stored conversation', err);
}
if (Array.isArray(stored) && stored.length) {
for (const pair of stored) {
if (pair?.user) {
state.messages.push({ role: 'user', content: pair.user });
}
if (pair?.assistant) {
state.messages.push({ role: 'assistant', content: pair.assistant });
}
}
} else if (stored && Array.isArray(stored.messages)) {
state.messages = stored.messages.map(msg => ({ ...msg }));
if (stored.model) {
state.config.model = stored.model;
}
}
ensureSystemMessage();
renderConversation();
}
function formatUserPreview(promptText) {
return promptText;
}
async function handleFileChange(event) {
const file = event.target.files && event.target.files[0];
dom.fileInput.value = '';
if (!file) {
clearAttachment();
return;
}
try {
setAttachmentLoading(true);
const attachment = await buildAttachment(file);
if (!attachment) {
appendMessage('system', 'Please upload a PNG/JPG image or a text file (.txt, .md, .tex, or other plain text).');
clearAttachment();
return;
}
setComposerAttachment(attachment);
} catch (err) {
appendMessage('system', `Failed to read file: ${err.message}`);
clearAttachment();
} finally {
setAttachmentLoading(false);
}
}
function readFileAsDataURL(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
});
}
function textToDataUrl(text, mime = 'text/plain') {
const safeMime = mime || 'text/plain';
const encoded = btoa(unescape(encodeURIComponent(text || '')));
return `data:${safeMime};base64,${encoded}`;
}
function base64ToUtf8(base64) {
try {
return decodeURIComponent(escape(atob(base64)));
} catch (err) {
return '';
}
}
function getFileExtension(name) {
if (!name) return '';
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot).toLowerCase() : '';
}
function isAllowedByMime(mime) {
if (!mime) return false;
return ALLOWED_MIME_TYPES.has(mime) || (mime.startsWith('text/') && ALLOWED_MIME_TYPES.has('text/*'));
}
function isAllowedByExtension(ext) {
if (!ext) return false;
return ALLOWED_EXTENSIONS.has(ext);
}
function detectAttachmentKind(mime, ext) {
const lowerMime = mime || '';
if (IMAGE_MIME_TYPES.has(lowerMime) || IMAGE_EXTENSIONS.has(ext)) return 'image';
if (lowerMime.startsWith('text/') || TEXT_MIME_TYPES.has(lowerMime) || TEXT_EXTENSIONS.has(ext)) return 'text';
return null;
}
function formatFileSize(size) {
if (typeof size !== 'number' || size <= 0) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let value = size;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
}
function cloneAttachment(src) {
if (!src) return null;
return {
kind: src.kind,
name: src.name,
dataUrl: src.dataUrl || null,
textContent: src.textContent || '',
mime: src.mime || '',
size: typeof src.size === 'number' ? src.size : null
};
}
function buildTextPreview(text) {
const maxChars = 1200;
const maxLines = 20;
const lines = (text || '').split(/\r?\n/);
let snippet = lines.slice(0, maxLines).join('\n');
let truncated = lines.length > maxLines;
if (snippet.length > maxChars) {
snippet = snippet.slice(0, maxChars);
truncated = true;
}
return { snippet, truncated };
}
function makePreviewData(attachment) {
if (!attachment) return null;
const ext = getFileExtension(attachment.name);
const isMarkdown = ext === '.md' || ext === '.markdown';
const isTex = ext === '.tex';
if (attachment.kind === 'image' && attachment.dataUrl) {
return {
kind: 'image',
name: attachment.name,
mime: attachment.mime || 'image/*',
size: attachment.size,
imageUrl: attachment.dataUrl,
note: 'Image preview. Click to open larger view.'
};
}
if (attachment.kind === 'text') {
const { snippet, truncated } = buildTextPreview(attachment.textContent || '');
return {
kind: isMarkdown || isTex ? 'markdown' : 'text',
name: attachment.name,
mime: attachment.mime || 'text/plain',
size: attachment.size,
text: snippet,
truncated,
isMarkdown,
isTex,
note: truncated ? 'Preview truncated for brevity.' : 'Full text available.'
};
}
return {
kind: 'unknown',
name: attachment.name,
mime: attachment.mime || 'application/octet-stream',
size: attachment.size,
note: 'Preview not available. Download to view.'
};
}
let activePopover = null;
let popoverAnchor = null;
let hidePopoverTimer = null;
let activeModal = null;
function closeAttachmentPopover() {
if (activePopover && activePopover.parentNode) {
activePopover.parentNode.removeChild(activePopover);
}
activePopover = null;
popoverAnchor = null;
}
function scheduleHidePopover(delay = 120) {
clearTimeout(hidePopoverTimer);
hidePopoverTimer = setTimeout(() => {
closeAttachmentPopover();
}, delay);
}
function createActionButton(icon, label, handler) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'preview-action';
btn.innerHTML = `<span class="glyphicon ${icon}" aria-hidden="true"></span><span>${label}</span>`;
btn.addEventListener('click', handler);
return btn;
}
function downloadAttachment(attachment) {
if (!attachment) return;
try {
const name = attachment.name || 'attachment';
if (attachment.dataUrl) {
const link = document.createElement('a');
link.href = attachment.dataUrl;
link.download = name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
return;
}
const text = attachment.textContent || '';
const blob = new Blob([text], { type: attachment.mime || 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (err) {
console.warn('Download failed', err);
}
}
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text || '');
} catch (err) {
console.warn('Clipboard copy failed', err);
}
}
function openInNewTab(attachment) {
if (!attachment) return;
if (attachment.kind === 'image' && attachment.dataUrl) {
const popup = window.open('', '_blank');
if (!popup) return;
const safeTitle = escapeHTML(attachment.name || 'Image');
popup.document.write(`
<html>
<head>
<title>${safeTitle}</title>
<style>
body { margin: 0; display: flex; align-items: center; justify-content: center; background: #111; }
img { max-width: 100vw; max-height: 100vh; }
</style>
</head>
<body>
<img src="${attachment.dataUrl}" alt="${safeTitle}"/>
</body>
</html>
`);
popup.document.close();
} else if (attachment.kind === 'text') {
const blob = new Blob([attachment.textContent || ''], { type: attachment.mime || 'text/plain' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
}
function closeAttachmentModal() {
if (activeModal && activeModal.parentNode) {
activeModal.parentNode.removeChild(activeModal);
}
activeModal = null;
}
function openAttachmentModal(preview, attachment) {
closeAttachmentModal();
if (!preview) return;
const backdrop = document.createElement('div');
backdrop.className = 'attachment-modal-backdrop';
backdrop.addEventListener('click', event => {
if (event.target === backdrop) closeAttachmentModal();
});
const modal = document.createElement('div');
modal.className = 'attachment-modal';
const header = document.createElement('div');
header.className = 'modal-header';
const title = document.createElement('h4');
title.textContent = preview.name || 'Attachment';
header.appendChild(title);
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'close-modal';
closeBtn.innerHTML = '<span class="glyphicon glyphicon-remove" aria-hidden="true"></span>';
closeBtn.addEventListener('click', closeAttachmentModal);
header.appendChild(closeBtn);
const body = document.createElement('div');
body.className = 'modal-body';
if (preview.kind === 'image' && preview.imageUrl) {
const img = document.createElement('img');
img.src = preview.imageUrl;
img.alt = preview.name || 'Attachment image';
img.style.maxWidth = '100%';
img.style.display = 'block';
body.appendChild(img);
} else {
const content = attachment?.textContent || preview.text || '';
if (preview.kind === 'markdown' && markdownSupport.enabled) {
const div = document.createElement('div');
div.innerHTML = renderMarkdown(content);
body.appendChild(div);
} else {
const pre = document.createElement('pre');
pre.textContent = content;
body.appendChild(pre);
}
}
modal.appendChild(header);
modal.appendChild(body);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
activeModal = backdrop;
window.addEventListener('keydown', function escHandler(event) {
if (event.key === 'Escape') {
closeAttachmentModal();
window.removeEventListener('keydown', escHandler);
}
}, { once: true });
}
function renderPreviewBody(container, preview) {
if (!container || !preview) return;
if (preview.kind === 'image' && preview.imageUrl) {
const img = document.createElement('img');
img.src = preview.imageUrl;
img.alt = preview.name || 'Attachment image';
img.className = 'preview-image';
container.appendChild(img);
return;
}
const body = document.createElement('div');
body.className = 'preview-body';
if (preview.kind === 'markdown' && markdownSupport.enabled) {
body.innerHTML = renderMarkdown(preview.text || '');
} else {
body.textContent = preview.text || 'Preview unavailable.';
}
container.appendChild(body);
}
function showAttachmentPopover(anchor, attachment) {
if (!anchor || !attachment) return;
const preview = makePreviewData(attachment);
if (!preview) return;
clearTimeout(hidePopoverTimer);
closeAttachmentPopover();
const host = anchor.parentElement || anchor;
const hostStyle = window.getComputedStyle(host);
if (hostStyle.position === 'static') {
host.style.position = 'relative';
}
const popover = document.createElement('div');
popover.className = 'attachment-preview';
popover.setAttribute('role', 'dialog');
const title = document.createElement('h5');
title.textContent = preview.name || 'Attachment';
popover.appendChild(title);
const meta = document.createElement('div');
meta.className = 'meta';
const parts = [];
if (preview.mime) parts.push(preview.mime);
if (preview.size) parts.push(formatFileSize(preview.size));
meta.textContent = parts.join(' • ');
popover.appendChild(meta);
renderPreviewBody(popover, preview);
const actions = document.createElement('div');
actions.className = 'actions';
actions.appendChild(createActionButton('glyphicon-new-window', 'Full preview', () => openAttachmentModal(preview, attachment)));
actions.appendChild(createActionButton('glyphicon-download', 'Download', () => downloadAttachment(attachment)));
if (preview.kind === 'image') {
actions.appendChild(createActionButton('glyphicon-picture', 'Open tab', () => openInNewTab(attachment)));
}
if (preview.kind === 'text' || preview.kind === 'markdown') {
actions.appendChild(createActionButton('glyphicon-copy', 'Copy snippet', () => copyToClipboard(preview.text || '')));
actions.appendChild(createActionButton('glyphicon-list-alt', 'Copy all', () => copyToClipboard(attachment.textContent || preview.text || '')));
}
popover.appendChild(actions);
if (preview.note) {
const note = document.createElement('div');
note.className = 'preview-note';
note.textContent = preview.note;
popover.appendChild(note);
}
popover.addEventListener('mouseenter', () => clearTimeout(hidePopoverTimer));
popover.addEventListener('mouseleave', () => scheduleHidePopover());
host.appendChild(popover);
const rect = anchor.getBoundingClientRect();
const viewportMid = window.innerHeight / 2;
const placeAbove = rect.top > viewportMid;
if (placeAbove) {
popover.classList.add('above');
} else {
popover.classList.remove('above');
}
activePopover = popover;
popoverAnchor = anchor;
}
function setupAttachmentPreviewTrigger(anchor, attachmentProvider) {
if (!anchor) return;
const handler = () => {
const attachment = typeof attachmentProvider === 'function' ? attachmentProvider() : attachmentProvider;
if (!attachment) return;
showAttachmentPopover(anchor, attachment);
};
anchor.addEventListener('mouseenter', handler);
anchor.addEventListener('focus', handler);
anchor.addEventListener('mouseleave', () => scheduleHidePopover());
anchor.addEventListener('blur', () => scheduleHidePopover());
}
function createAttachmentChip(attachment) {
const chip = document.createElement('div');
chip.className = 'attachment-chip';
chip.tabIndex = 0;
const icon = document.createElement('span');
const iconClass = attachment?.kind === 'image' ? 'glyphicon-picture' : (attachment?.kind === 'text' ? 'glyphicon-file' : 'glyphicon-paperclip');
icon.className = `glyphicon ${iconClass}`;
chip.appendChild(icon);
const label = document.createElement('span');
label.className = 'attachment-label';
label.textContent = attachment?.name || 'Attachment';
chip.appendChild(label);
setupAttachmentPreviewTrigger(chip, () => attachment);
return chip;
}
async function buildAttachment(file) {
const name = file.name || 'attachment';
const mime = (file.type || '').toLowerCase();
const ext = getFileExtension(name);
const allowed = isAllowedByMime(mime) || isAllowedByExtension(ext);
const kind = detectAttachmentKind(mime, ext);
if (!allowed || !kind) return null;
if (kind === 'image') {
const dataUrl = await readFileAsDataURL(file);
return { kind: 'image', name, dataUrl, mime, size: file.size };
}
if (kind === 'text') {
const textContent = await readFileAsText(file);
const dataUrl = textToDataUrl(textContent, mime || 'text/plain');
return { kind: 'text', name, textContent, dataUrl, mime, size: file.size };
}
return null;
}
dom.form.addEventListener('submit', async event => {
event.preventDefault();
if (state.busy) return;
const prompt = dom.input.value.trim();
if (!prompt) return;
ensureSystemMessage();
const userAttachment = cloneAttachment(state.attachment);
const userMessage = buildUserMessage(prompt);
if (userAttachment) {
userMessage.attachment = userAttachment;
}
// add user message to state immediately so edit/trim is available right away
state.messages.push({
role: 'user',
content: userMessage.content,
attachment: userAttachment,
search: !!state.searchMode
});
const userIndex = state.messages.length - 1;
const preview = formatUserPreview(prompt);
state.busy = true;
dom.sendButton.disabled = true;
appendMessage('user', preview, { attachment: userAttachment, messageIndex: userIndex });
dom.input.value = '';
clearAttachment();
resizeComposer();
const assistantNode = appendMessage('assistant', 'waiting for an answer...');
try {
await streamChat(userMessage, assistantNode, { onFirstToken: showComposer, includeUserInPayload: false, pushUserToState: false });
} catch (err) {
appendMessage('system', `Error: ${err.message}`);
showComposer();
} finally {
state.busy = false;
dom.sendButton.disabled = false;
showComposer();
}
});
dom.addFileButton.addEventListener('click', () => {
dom.fileInput.click();
});
dom.searchButton?.addEventListener('click', () => {
state.searchMode = true;
state.attachment = null;
dom.attachmentRow.classList.remove('has-attachment');
dom.attachmentRow.classList.add('search-mode');
dom.attachmentFilename.textContent = 'Attach Search Results';
});
dom.fileInput.addEventListener('change', handleFileChange);
dom.clearFileButton.addEventListener('click', () => {
clearAttachment();
});
dom.clearChatButton?.addEventListener('click', clearChatHistory);
dom.downloadChatButton?.addEventListener('click', downloadChat);
dom.uploadChatButton?.addEventListener('click', () => dom.uploadChatInput?.click());
dom.uploadChatInput?.addEventListener('change', handleUploadChatFile);
dom.toggleSystemButton?.addEventListener('click', () => {
state.showSystem = !state.showSystem;
renderConversation();
});
window.addEventListener('scroll', closeAttachmentPopover);
dom.input.addEventListener('input', resizeComposer);
dom.input.addEventListener('input', ensureComposerVisible);
dom.input.addEventListener('keydown', event => {
if (event.isComposing) return;
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
if (typeof dom.form.requestSubmit === 'function') {
dom.form.requestSubmit(dom.sendButton);
} else {
dom.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
}
});
dom.composerMain?.addEventListener('dragover', event => {
event.preventDefault();
});
dom.composerMain?.addEventListener('drop', async event => {
event.preventDefault();
const file = event.dataTransfer?.files && event.dataTransfer.files[0];
if (!file) return;
try {
setAttachmentLoading(true);
const attachment = await buildAttachment(file);
if (!attachment) {
appendMessage('system', 'Please drop a PNG/JPG image or a text file (.txt, .md, .tex, or other plain text).');
return;
}
setComposerAttachment(attachment);
} catch (err) {
appendMessage('system', `Failed to read file: ${err.message}`);
clearAttachment();
} finally {
setAttachmentLoading(false);
}
});
window.addEventListener('resize', resizeComposer);
hydrateConversation();
updateSystemToggleButton();
resizeComposer();
updateClearChatVisibility();
</script>
#%env/templates/footer.template%#
</body>
</html>