uncloseai.com/public/src/chat.js

368 lines
12 KiB
JavaScript

// This is free software for the public good of a permacomputer hosted at
// permacomputer.com, an always-on computer by the people, for the people.
// One which is durable, easy to repair, & distributed like tap water
// for machine learning intelligence.
//
// The permacomputer is community-owned infrastructure optimized around
// four values:
//
// TRUTH First principles, math & science, open source code freely distributed
// FREEDOM Voluntary partnerships, freedom from tyranny & corporate control
// HARMONY Minimal waste, self-renewing systems with diverse thriving connections
// LOVE Be yourself without hurting others, cooperation through natural law
//
// This software contributes to that vision by making machine learning
// accessible to everyone through a free, open, embeddable chat interface.
// Code is seeds to sprout on any abandoned technology.
import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js";
import hljs from "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/es/highlight.min.js";
import { API_KEY, getAPIConfig } from "./config.js";
import { getSelectedModel, getSelectedModelEndpoint, getSelectedModelMaxTokens, getSelectedModelMaxCompletionTokens } from "./models.js";
import { groqAwareStreamingFetch } from "./groq-fetch.js";
// Rough token estimation (4 chars = 1 token average for English)
function estimateTokens(text) {
return Math.ceil(text.length / 4);
}
// Calculate available output tokens
function calculateAvailableTokens(chatHistory, maxTokens) {
const inputText = chatHistory.map(msg => msg.content).join('');
const inputTokens = estimateTokens(inputText);
// For large context models (>32k), use a more reasonable buffer
// For smaller models, be more conservative
let buffer;
if (maxTokens > 32000) {
// Large context models: just reserve 10% or 2k tokens for safety
buffer = Math.min(2000, Math.floor(maxTokens * 0.1));
} else if (maxTokens > 8000) {
// Medium models: reserve 20% or 1.5k tokens
buffer = Math.min(1500, Math.floor(maxTokens * 0.2));
} else {
// Small models: keep conservative approach
buffer = Math.max(1000, Math.floor(inputTokens * 0.5));
}
const availableTokens = Math.max(2000, maxTokens - inputTokens - buffer);
console.log(`Token calculation: max=${maxTokens}, input≈${inputTokens}, buffer=${buffer}, available≈${availableTokens}`);
return availableTokens;
}
import { initializeChatHistory, saveConversationHistory, getCustomPromptEcho } from "./storage.js";
import { generateTitleForTTS, speakText } from "./tts.js";
// Helper function to add copy buttons to code blocks
function addCodeBlockCopyButtons(element) {
const codeBlocks = element.querySelectorAll("pre code");
codeBlocks.forEach((codeBlock) => {
const pre = codeBlock.parentElement;
if (pre.tagName.toLowerCase() === "pre") {
// Check if copy button already exists to avoid duplicates
if (pre.querySelector('.code-copy-btn')) {
return;
}
// Make the pre element relative for positioning
pre.style.position = "relative";
// Create copy button
const copyBtn = document.createElement("button");
copyBtn.className = "uncloseai-code-copy-btn";
copyBtn.textContent = "📋";
copyBtn.title = "Copy code";
// Styling handled by CSS class
copyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(codeBlock.textContent);
copyBtn.textContent = "✓";
setTimeout(() => {
copyBtn.textContent = "📋";
}, 2000);
} catch (error) {
console.error("Failed to copy code:", error);
}
};
pre.appendChild(copyBtn);
}
});
}
// Initialize chat history (will be populated on first use)
export let chatHistory = [];
// Generator function to send a message to the LLM and yield responses
export async function* sendMessage(message) {
// Initialize chat history if empty
if (chatHistory.length === 0) {
chatHistory = await initializeChatHistory();
}
chatHistory.push({ role: "user", content: message });
// Get API configuration (custom or default)
const apiConfig = await getAPIConfig();
let apiUrl, headers, model;
if (apiConfig.isCustom) {
// Use custom API configuration
apiUrl = `${apiConfig.endpoint}/chat/completions`;
headers = apiConfig.headers;
model = apiConfig.model;
} else {
// Use default Hermes configuration
apiUrl = `${getSelectedModelEndpoint()}/chat/completions`;
headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
"X-Uncloseai-Client": "browser-toy",
};
model = getSelectedModel();
}
// Build messages for LLM: chatHistory + echo (echo is only for LLM attention, not stored)
const echo = getCustomPromptEcho();
const messagesForLLM = echo
? [...chatHistory, { role: "assistant", content: echo }]
: chatHistory;
const modelMaxTokens = getSelectedModelMaxTokens();
const calculatedAvailable = calculateAvailableTokens(messagesForLLM, modelMaxTokens);
// Cap to model's max completion tokens limit
const maxCompletionTokens = getSelectedModelMaxCompletionTokens();
// Use the full calculated available space or the model's limit, whichever is smaller
const finalAvailable = Math.min(calculatedAvailable, maxCompletionTokens);
console.log("Chat using tokens:", finalAvailable, "calculated:", calculatedAvailable, "max completion:", maxCompletionTokens);
const requestBody = JSON.stringify({
model: model,
messages: messagesForLLM,
temperature: 0.5,
max_tokens: finalAvailable,
stream: true,
});
const streamGenerator = groqAwareStreamingFetch(apiUrl, {
method: "POST",
headers: headers,
body: requestBody,
});
let buffer = "";
for await (const chunk of streamGenerator) {
buffer += chunk;
const lines = buffer.split("\n");
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i].trim();
if (line.startsWith("data: ")) {
const jsonData = line.slice(6);
if (jsonData === "[DONE]") continue;
try {
const parsedData = JSON.parse(jsonData);
const content = parsedData.choices[0].delta.content;
if (content) {
yield content;
}
} catch (error) {
console.error("Error parsing JSON:", error);
}
}
}
buffer = lines[lines.length - 1];
}
}
// Function to handle user input and display responses
export async function handleUserInput() {
const userInput = document.getElementById("user-input").value;
document.getElementById("user-input").value = "";
const chatBox = document.getElementById("chat-box");
chatBox.innerHTML += `<p><strong>You:</strong> ${userInput}</p>`;
const aiResponseParagraph = document.createElement("p");
aiResponseParagraph.innerHTML = "<strong>AI:</strong> ";
chatBox.appendChild(aiResponseParagraph);
const responseContent = document.createElement("span");
aiResponseParagraph.appendChild(responseContent);
let accumulatedContent = "";
for await (const chunk of sendMessage(userInput)) {
accumulatedContent += chunk;
const parsedChunk = marked.parse(accumulatedContent);
responseContent.innerHTML = parsedChunk;
responseContent.querySelectorAll("pre code").forEach((block) => {
hljs.highlightElement(block);
});
}
// Streaming is complete - add copy buttons to code blocks
addCodeBlockCopyButtons(responseContent);
chatBox.scrollTop = chatBox.scrollHeight;
// Add the response to chat history
chatHistory.push({ role: "assistant", content: accumulatedContent });
saveConversationHistory(chatHistory);
// Add play/pause button for TTS
const playPauseButton = document.createElement("button");
playPauseButton.textContent = "Generate TTS for AI Response";
playPauseButton.className = "uncloseai-ui-button-margin";
let aiAudio = null;
let aiBlob = null;
let isPaused = false;
playPauseButton.onclick = async () => {
if (!aiAudio) {
playPauseButton.textContent = "Processing...";
playPauseButton.disabled = true; // Disable button while processing
const mainVoiceSelect = document.getElementById("read-page-voice");
const selectedVoice = mainVoiceSelect ? mainVoiceSelect.value : "alloy";
const result = await speakText(accumulatedContent, selectedVoice, 0.9);
aiAudio = result.audio;
aiBlob = result.blob;
playPauseButton.textContent = "Pause AI Response";
playPauseButton.disabled = false; // Re-enable button after processing
aiAudio.play();
// Generate title for the MP3 file
const title = await generateTitleForTTS(accumulatedContent);
// Add download button
const downloadButton = document.createElement("button");
downloadButton.textContent = "Download MP3";
downloadButton.className = "uncloseai-ui-button-margin";
downloadButton.onclick = () => {
const a = document.createElement("a");
a.href = URL.createObjectURL(aiBlob);
a.download = `${title}.mp3`;
a.click();
};
chatBox.appendChild(downloadButton);
} else {
if (isPaused) {
aiAudio.play();
playPauseButton.textContent = "Pause AI Response";
} else {
aiAudio.pause();
playPauseButton.textContent = "Play AI Response";
}
isPaused = !isPaused;
}
};
chatBox.appendChild(playPauseButton);
}
// Update chat history reference (for external modules)
export function updateChatHistory(newHistory) {
chatHistory = newHistory;
}
export function getChatHistory() {
return chatHistory;
}
// Generator function to send a message with custom history (doesn't modify global chatHistory)
export async function* sendMessageWithCustomHistory(customHistory) {
// Get API configuration (custom or default)
const apiConfig = await getAPIConfig();
let apiUrl, headers, model;
if (apiConfig.isCustom) {
// Use custom API configuration
apiUrl = `${apiConfig.endpoint}/chat/completions`;
headers = apiConfig.headers;
model = apiConfig.model;
} else {
// Use default Hermes configuration
apiUrl = `${getSelectedModelEndpoint()}/chat/completions`;
headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
"X-Uncloseai-Client": "browser-toy",
};
model = getSelectedModel();
}
// Build messages for LLM: customHistory + echo (echo is only for LLM attention, not stored)
const echo = getCustomPromptEcho();
const messagesForLLM = echo
? [...customHistory, { role: "assistant", content: echo }]
: customHistory;
const modelMaxTokens = getSelectedModelMaxTokens();
const calculatedAvailable = calculateAvailableTokens(messagesForLLM, modelMaxTokens);
// Cap to model's max completion tokens limit
const maxCompletionTokens = getSelectedModelMaxCompletionTokens();
const finalAvailable = Math.min(calculatedAvailable, maxCompletionTokens);
console.log("Chat (custom history) using tokens:", finalAvailable, "calculated:", calculatedAvailable, "max completion:", maxCompletionTokens);
const response = await fetch(apiUrl, {
method: "POST",
headers: headers,
body: JSON.stringify({
model: model,
messages: messagesForLLM,
max_tokens: finalAvailable,
stream: true,
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error("Chat API Error:", {
status: response.status,
url: apiUrl,
response: errorText
});
throw new Error(`HTTP error! status: ${response.status}: ${errorText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") {
return;
}
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
yield content;
}
} catch (error) {
// Skip malformed JSON
}
}
}
}
} finally {
reader.releaseLock();
}
}