mirror of
https://github.com/yacy/yacy_search_server.git
synced 2025-12-13 04:14:35 -05:00
1914 lines
66 KiB
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>
|