LLM integration with all the tabs
This commit is contained in:
180
lib/llm.js
Normal file
180
lib/llm.js
Normal file
@@ -0,0 +1,180 @@
|
||||
// Shared LLM module - provides streaming chat to any page
|
||||
var LLM = (function() {
|
||||
var defaultApiUrl = 'https://llama-instruct.reeselink.com/v1';
|
||||
var defaultModel = 'instruct';
|
||||
|
||||
function getConfig() {
|
||||
return {
|
||||
apiUrl: localStorage.getItem('apiUrl') || defaultApiUrl,
|
||||
token: localStorage.getItem('apiToken') || '',
|
||||
model: localStorage.getItem('modelName') || defaultModel
|
||||
};
|
||||
}
|
||||
|
||||
function callAPI(messages, onChunk, onComplete, onError) {
|
||||
var config = getConfig();
|
||||
var apiUrl = config.apiUrl.replace(/\/+$/, '');
|
||||
var headers = { 'Content-Type': 'application/json' };
|
||||
if (config.token) {
|
||||
headers['Authorization'] = 'Bearer ' + config.token;
|
||||
}
|
||||
|
||||
fetch(apiUrl + '/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify({
|
||||
messages: messages,
|
||||
model: config.model,
|
||||
stream: true
|
||||
})
|
||||
})
|
||||
.then(function(response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('API error: ' + response.status + ' ' + response.statusText);
|
||||
}
|
||||
var reader = response.body.getReader();
|
||||
var decoder = new TextDecoder();
|
||||
var buffer = '';
|
||||
|
||||
function read() {
|
||||
return reader.read().then(function(result) {
|
||||
var done = result.done;
|
||||
var value = result.value;
|
||||
if (done) {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
var lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim();
|
||||
if (line.startsWith('data: ')) {
|
||||
var data = line.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var json = JSON.parse(data);
|
||||
var delta = json.choices && json.choices[0] && json.choices[0].delta;
|
||||
if (delta && delta.content) {
|
||||
onChunk(delta.content);
|
||||
}
|
||||
} catch (e) {
|
||||
// skip malformed JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
return read();
|
||||
});
|
||||
}
|
||||
return read();
|
||||
})
|
||||
.catch(function(err) {
|
||||
onError(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function chat(elementId, systemPrompt, userMessage) {
|
||||
var messages = [];
|
||||
if (systemPrompt) {
|
||||
messages.push({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
messages.push({ role: 'user', content: userMessage });
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
var outputEl = document.getElementById(elementId);
|
||||
if (!outputEl) { reject(new Error('Output element not found')); return; }
|
||||
|
||||
outputEl.innerHTML = '<span class="llm-loading">Thinking...</span>';
|
||||
|
||||
var fullText = '';
|
||||
var history = systemPrompt ? messages : [];
|
||||
|
||||
callAPI(
|
||||
history,
|
||||
function(chunk) {
|
||||
fullText += chunk;
|
||||
outputEl.innerHTML = formatMarkdown(fullText);
|
||||
outputEl.style.whiteSpace = 'pre-wrap';
|
||||
},
|
||||
function() {
|
||||
resolve(fullText);
|
||||
},
|
||||
function(err) {
|
||||
outputEl.innerHTML = '<span class="llm-error">Error: ' + escapeHTML(err) + '</span>';
|
||||
reject(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function chatWithHistory(elementId, history, onDone) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var outputEl = document.getElementById(elementId);
|
||||
if (!outputEl) { reject(new Error('Output element not found')); return; }
|
||||
|
||||
outputEl.innerHTML = '<span class="llm-loading">Thinking...</span>';
|
||||
|
||||
var fullText = '';
|
||||
|
||||
callAPI(
|
||||
history,
|
||||
function(chunk) {
|
||||
fullText += chunk;
|
||||
outputEl.innerHTML = formatMarkdown(fullText);
|
||||
outputEl.style.whiteSpace = 'pre-wrap';
|
||||
},
|
||||
function() {
|
||||
if (onDone) onDone();
|
||||
resolve(fullText);
|
||||
},
|
||||
function(err) {
|
||||
outputEl.innerHTML = '<span class="llm-error">Error: ' + escapeHTML(err) + '</span>';
|
||||
reject(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHTML(str) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatMarkdown(text) {
|
||||
// Extract code blocks first to protect them from escaping
|
||||
var codeBlocks = [];
|
||||
text = text.replace(/```([\s\S]*?)```/g, function(match, code) {
|
||||
var placeholder = '%%CODEBLOCK' + codeBlocks.length + '%%';
|
||||
codeBlocks.push('<pre class="llm-code-block"><code>' + escapeHTML(code) + '</code></pre>');
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// Escape remaining HTML
|
||||
text = escapeHTML(text);
|
||||
|
||||
// Restore code blocks
|
||||
for (var i = 0; i < codeBlocks.length; i++) {
|
||||
text = text.replace('%%CODEBLOCK' + i + '%%', codeBlocks[i]);
|
||||
}
|
||||
|
||||
// Inline code
|
||||
text = text.replace(/`([^`]+)`/g, '<code class="llm-inline-code">$1</code>');
|
||||
// Bold
|
||||
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
// Line breaks
|
||||
text = text.replace(/\n/g, '<br>');
|
||||
return text;
|
||||
}
|
||||
|
||||
return {
|
||||
getConfig: getConfig,
|
||||
callAPI: callAPI,
|
||||
chat: chat,
|
||||
chatWithHistory: chatWithHistory
|
||||
};
|
||||
})();
|
||||
117
lib/modal.js
Normal file
117
lib/modal.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// Shared modal for LLM explanations
|
||||
var LLMModal = (function() {
|
||||
var modalEl = null;
|
||||
var contentEl = null;
|
||||
var isOpen = false;
|
||||
|
||||
function createModal() {
|
||||
if (modalEl) return;
|
||||
modalEl = document.createElement('div');
|
||||
modalEl.className = 'llm-modal-backdrop';
|
||||
modalEl.style.cssText = 'display:none; position:fixed; top:0; left:0; width:100%; height:100%; z-index:9999; background:rgba(0,0,0,0.5); justify-content:center; align-items:center; padding:2rem;';
|
||||
|
||||
var container = document.createElement('div');
|
||||
container.className = 'llm-modal-container';
|
||||
container.style.cssText = 'background:var(--white); border-radius:20px; max-width:700px; width:100%; max-height:80vh; display:flex; flex-direction:column; box-shadow:0 20px 60px rgba(0,0,0,0.3); overflow:hidden;';
|
||||
|
||||
var header = document.createElement('div');
|
||||
header.className = 'llm-modal-header';
|
||||
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding:1.2rem 1.5rem; border-bottom:2px solid var(--pink-200);';
|
||||
|
||||
var title = document.createElement('h3');
|
||||
title.className = 'llm-modal-title';
|
||||
title.style.cssText = 'color:var(--pink-600); font-size:1.1rem; margin:0;';
|
||||
|
||||
var closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'llm-modal-close';
|
||||
closeBtn.innerHTML = '✕';
|
||||
closeBtn.style.cssText = 'background:none; border:none; color:var(--pink-500); font-size:1.5rem; cursor:pointer; padding:0.2rem 0.5rem; line-height:1; transition:color 0.2s;';
|
||||
closeBtn.addEventListener('mouseenter', function() { this.style.color = 'var(--pink-700)'; });
|
||||
closeBtn.addEventListener('mouseleave', function() { this.style.color = 'var(--pink-500)'; });
|
||||
closeBtn.addEventListener('click', close);
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.className = 'llm-modal-body';
|
||||
body.id = 'llm-modal-content';
|
||||
body.style.cssText = 'padding:1.5rem; overflow-y:auto; flex:1; font-size:0.95rem; line-height:1.7; color:var(--pink-900); white-space:pre-wrap;';
|
||||
|
||||
container.appendChild(header);
|
||||
container.appendChild(body);
|
||||
modalEl.appendChild(container);
|
||||
document.body.appendChild(modalEl);
|
||||
|
||||
modalEl.addEventListener('click', function(e) {
|
||||
if (e.target === modalEl) close();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && isOpen) close();
|
||||
});
|
||||
|
||||
modalEl.style.display = 'flex';
|
||||
}
|
||||
|
||||
function open(titleText) {
|
||||
if (!modalEl) createModal();
|
||||
var title = modalEl.querySelector('.llm-modal-title');
|
||||
title.textContent = titleText;
|
||||
contentEl = document.getElementById('llm-modal-content');
|
||||
contentEl.innerHTML = '<span class="llm-loading">Thinking...</span>';
|
||||
modalEl.style.display = 'flex';
|
||||
isOpen = true;
|
||||
}
|
||||
|
||||
function update(text) {
|
||||
if (contentEl) {
|
||||
contentEl.innerHTML = formatMarkdown(text);
|
||||
}
|
||||
}
|
||||
|
||||
function done() {
|
||||
// nothing to do, modal stays open
|
||||
}
|
||||
|
||||
function error(msg) {
|
||||
if (contentEl) {
|
||||
contentEl.innerHTML = '<span class="llm-error">Error: ' + escapeHTML(msg) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (modalEl) {
|
||||
modalEl.style.display = 'none';
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(str) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatMarkdown(text) {
|
||||
// Escape HTML first
|
||||
text = escapeHTML(text);
|
||||
// Code blocks (must be before inline code)
|
||||
text = text.replace(/```([\s\S]*?)```/g, '<pre class="llm-code-block"><code>$1</code></pre>');
|
||||
// Inline code
|
||||
text = text.replace(/`([^`]+)`/g, '<code class="llm-inline-code">$1</code>');
|
||||
// Bold
|
||||
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
// Line breaks
|
||||
text = text.replace(/\n/g, '<br>');
|
||||
return text;
|
||||
}
|
||||
|
||||
return {
|
||||
open: open,
|
||||
update: update,
|
||||
done: done,
|
||||
error: error,
|
||||
close: close
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user