// Application State (will be populated from IndexedDB) const state = { apiKey: "", systemPrompt: "You are a helpful assistant.", temperature: 1.0, maxTokens: 4096, topP: 1.0, frequencyPenalty: 0.0, presencePenalty: 0.0, selectedModel: "openai/gpt-oss-120b", reasoningEffort: "medium", autoShowReasoning: false, contextLimit: 100, theme: "light", // NOTE: state.messages is the in-memory context sent to API (trimmed to contextLimit) // Full conversation history is persisted in IndexedDB via conversationManager messages: [], isStreaming: false, abortController: null, lastUsage: null, totalCost: 0, isInitialLoad: true // Track if this is the first conversation load }; // Model pricing (USD per 1M tokens) - OpenRouter prices const MODEL_PRICING = { "openai/gpt-oss-20b": { input: 1.5, output: 6.0 }, "openai/gpt-oss-120b": { input: 3.0, output: 12.0 } }; // Calculate cost from usage function calculateCost(usage, model) { const pricing = MODEL_PRICING[model]; if (!pricing || !usage) return 0; const inputCost = ((usage.prompt_tokens || 0) / 1000000) * pricing.input; const outputCost = ((usage.completion_tokens || 0) / 1000000) * pricing.output; return inputCost + outputCost; } // DOM Elements const elements = { sidebar: document.getElementById("sidebar"), sidebarOverlay: document.getElementById("sidebarOverlay"), settingsBtn: document.getElementById("settingsBtn"), toggleSidebar: document.getElementById("toggleSidebar"), newChatBtn: document.getElementById("newChatBtn"), settingsModal: document.getElementById("settingsModal"), closeModal: document.getElementById("closeModal"), saveSettings: document.getElementById("saveSettings"), clearSettings: document.getElementById("clearSettings"), themeToggle: document.getElementById("themeToggle"), themeText: document.getElementById("themeText"), chatMessages: document.getElementById("chatMessages"), messageInput: document.getElementById("messageInput"), sendBtn: document.getElementById("sendBtn"), modelCards: document.querySelectorAll(".model-card"), reasoningSection: document.getElementById("reasoningSection"), // Settings form elements apiKeyInput: document.getElementById("apiKey"), systemPromptInput: document.getElementById("systemPrompt"), temperatureInput: document.getElementById("temperature"), tempValue: document.getElementById("tempValue"), maxTokensInput: document.getElementById("maxTokens"), topPInput: document.getElementById("topP"), topPValue: document.getElementById("topPValue"), frequencyPenaltyInput: document.getElementById("frequencyPenalty"), freqValue: document.getElementById("freqValue"), presencePenaltyInput: document.getElementById("presencePenalty"), presValue: document.getElementById("presValue"), autoShowReasoningInput: document.getElementById("autoShowReasoning"), contextLimitInput: document.getElementById("contextLimit"), messageCounter: document.getElementById("messageCounter"), // New UI elements scrollToBottomBtn: document.getElementById("scrollToBottomBtn"), focusModeBtn: document.getElementById("focusModeBtn") }; // NOTE: escapeHtml is defined in utils.js - use that global function // Helper function to manage context window function trimContextWindow() { // Keep only the last N messages (contextLimit = N pairs of user/assistant) const maxMessages = state.contextLimit * 2; // user + assistant pairs if (state.messages.length > maxMessages) { state.messages = state.messages.slice(-maxMessages); } updateMessageCounter(); } // Update message counter display function updateMessageCounter() { const messagePairs = Math.floor(state.messages.length / 2); const maxPairs = state.contextLimit; elements.messageCounter.textContent = `Messages: ${messagePairs} / ${maxPairs} pairs`; } // ======================================== // Conversation Management Functions // ======================================== // Start a new conversation async function startNewConversation() { try { // Reset session cost when starting new conversation state.totalCost = 0; state.lastUsage = null; updateTokenCounter(); // Create and load new conversation (callback handles UI update) const convId = await conversationManager.createConversation("New Chat", state.selectedModel); await conversationManager.loadConversation(convId); showNotification("New conversation started", "success"); return convId; } catch (error) { showNotification("Failed to create new conversation", "error"); console.error(error); } } // Import a conversation from JSON file async function importConversation() { try { const data = await showImportFilePicker(); if (!data) return; // User cancelled or invalid file const convId = await conversationManager.importConversation(data); await conversationManager.loadConversation(convId); await conversationsUI.refresh(); showNotification(`Imported "${data.conversation.title}"`, "success"); } catch (error) { showNotification("Failed to import conversation: " + error.message, "error"); console.error(error); } } // Load a conversation into the UI (called by onConversationChange callback) function loadConversationIntoUI(conversation, messages) { // Reset session cost when switching conversations state.totalCost = 0; state.lastUsage = null; updateTokenCounter(false); // Clear and load messages elements.chatMessages.innerHTML = ""; if (messages.length === 0) { elements.chatMessages.innerHTML = getWelcomeHTML(); state.messages = []; } else { // Apply context limit: keep only the last N message pairs const maxMessages = state.contextLimit * 2; const trimmedMessages = messages.length > maxMessages ? messages.slice(-maxMessages) : messages; trimmedMessages.forEach(msg => { addMessage(msg.role, msg.content, msg.reasoning, msg.timestamp, msg.id); }); state.messages = trimmedMessages.map(msg => ({ role: msg.role, content: msg.content })); } // Update model if conversation uses different model if (conversation.model !== state.selectedModel) { selectModel(conversation.model); } updateMessageCounter(); // Refresh conversation list to update active state (single call) conversationsUI.refresh(); // Show notification when switching conversations (but not on initial load) if (!state.isInitialLoad && messages.length > 0) { const msgCount = Math.floor(messages.length / 2); showNotification(`Loaded "${conversation.title}" (${msgCount} messages)`, "info"); } // Mark that initial load is complete state.isInitialLoad = false; } // Save current message to database async function saveMessageToDB(role, content, reasoning = null) { if (!conversationManager.currentConversationId) { await startNewConversation(); } try { await conversationManager.addMessage(conversationManager.currentConversationId, role, content, reasoning); // Auto-name conversation based on first user message const messages = await conversationManager.getMessages(conversationManager.currentConversationId); if (messages.length === 1 && role === "user") { await conversationManager.autoNameConversation(conversationManager.currentConversationId, content); await conversationsUI.refresh(); } } catch (error) { console.error("Failed to save message:", error); showNotification("Warning: Message not saved to database", "warning"); throw error; // Propagate error so caller knows save failed } } // Initialize App async function init() { await loadSettingsFromDB(); setupEventListeners(); applyTheme(); updateModelSelection(); updateReasoningSection(); checkApiKey(); restoreFocusMode(); // Restore focus mode preference conversationsUI.init(); await initializeConversations(); } // Load settings from IndexedDB into state async function loadSettingsFromDB() { const settings = await settingsManager.getAll(); Object.assign(state, settings); loadSettingsToUI(); } // Load settings from state to UI function loadSettingsToUI() { elements.apiKeyInput.value = state.apiKey; elements.systemPromptInput.value = state.systemPrompt; elements.temperatureInput.value = state.temperature; elements.tempValue.textContent = state.temperature.toFixed(1); elements.maxTokensInput.value = state.maxTokens; elements.topPInput.value = state.topP; elements.topPValue.textContent = state.topP.toFixed(2); elements.frequencyPenaltyInput.value = state.frequencyPenalty; elements.freqValue.textContent = state.frequencyPenalty.toFixed(1); elements.presencePenaltyInput.value = state.presencePenalty; elements.presValue.textContent = state.presencePenalty.toFixed(1); elements.autoShowReasoningInput.checked = state.autoShowReasoning; elements.contextLimitInput.value = state.contextLimit; // Set reasoning effort const reasoningRadio = document.querySelector(`input[name="reasoning"][value="${state.reasoningEffort}"]`); if (reasoningRadio) reasoningRadio.checked = true; } // Initialize conversation system async function initializeConversations() { // Set up callback: loadConversation() always triggers this conversationManager.onConversationChange = (conversation, messages) => { loadConversationIntoUI(conversation, messages); }; // Load most recent conversation or create first one const allConversations = await conversationManager.getAllConversations(); if (allConversations.length === 0) { await startNewConversation(); } else { conversationManager.currentConversationId = allConversations[0].id; await conversationManager.loadConversation(allConversations[0].id); } } // Setup Event Listeners function setupEventListeners() { // New Chat button elements.newChatBtn.addEventListener("click", async () => { await startNewConversation(); }); // Import Chat button document.getElementById("importConversationBtn").addEventListener("click", async () => { await importConversation(); }); // Settings modal elements.settingsBtn.addEventListener("click", openSettings); elements.closeModal.addEventListener("click", closeSettings); elements.saveSettings.addEventListener("click", saveSettings); elements.clearSettings.addEventListener("click", clearSettings); // Edit message modal document.getElementById("closeEditModal").addEventListener("click", closeEditModal); document.getElementById("cancelEditMessage").addEventListener("click", closeEditModal); document.getElementById("confirmEditMessage").addEventListener("click", confirmEditMessage); // Close edit modal on backdrop click document.getElementById("editMessageModal").addEventListener("click", e => { if (e.target.id === "editMessageModal") { closeEditModal(); } }); // Handle Ctrl+Enter in edit modal textarea document.getElementById("editMessageText").addEventListener("keydown", e => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); confirmEditMessage(); } if (e.key === "Escape") { closeEditModal(); } }); // Click outside modal to close elements.settingsModal.addEventListener("click", e => { if (e.target === elements.settingsModal || e.target.classList.contains("modal-backdrop")) { closeSettings(); } }); // Theme toggle elements.themeToggle.addEventListener("click", toggleTheme); // Sidebar toggle (mobile) elements.toggleSidebar.addEventListener("click", e => { e.stopPropagation(); // Prevent click from bubbling const isOpen = elements.sidebar.classList.toggle("open"); elements.sidebarOverlay.classList.toggle("active", isOpen); // Prevent body scroll when sidebar is open on mobile if (isOpen) { document.body.style.overflow = "hidden"; } else { document.body.style.overflow = ""; } }); // Close sidebar when clicking on overlay elements.sidebarOverlay.addEventListener("click", () => { elements.sidebar.classList.remove("open"); elements.sidebarOverlay.classList.remove("active"); document.body.style.overflow = ""; }); // Close sidebar when clicking outside (mobile) - backup method document.addEventListener("click", e => { const sidebar = elements.sidebar; const toggleBtn = elements.toggleSidebar; // Check if sidebar is open and click is outside sidebar and toggle button if (sidebar.classList.contains("open") && !sidebar.contains(e.target) && !toggleBtn.contains(e.target)) { sidebar.classList.remove("open"); elements.sidebarOverlay.classList.remove("active"); document.body.style.overflow = ""; } }); // Prevent clicks inside sidebar from closing it elements.sidebar.addEventListener("click", e => { e.stopPropagation(); }); // Close sidebar with Escape key document.addEventListener("keydown", e => { if (e.key === "Escape" && elements.sidebar.classList.contains("open")) { elements.sidebar.classList.remove("open"); elements.sidebarOverlay.classList.remove("active"); document.body.style.overflow = ""; } }); // Model selection elements.modelCards.forEach(card => { card.addEventListener("click", () => { selectModel(card.dataset.model); }); }); // Reasoning effort document.querySelectorAll('input[name="reasoning"]').forEach(radio => { radio.addEventListener("change", async e => { state.reasoningEffort = e.target.value; await settingsManager.set("reasoningEffort", state.reasoningEffort); }); }); // Collapsible sidebar sections document.querySelectorAll(".section-toggle").forEach(toggle => { toggle.addEventListener("click", () => { const targetId = toggle.dataset.target; const content = document.getElementById(targetId); if (content) { toggle.classList.toggle("collapsed"); content.classList.toggle("hidden"); } }); }); // Range inputs elements.temperatureInput.addEventListener("input", e => { elements.tempValue.textContent = parseFloat(e.target.value).toFixed(1); }); elements.topPInput.addEventListener("input", e => { elements.topPValue.textContent = parseFloat(e.target.value).toFixed(2); }); elements.frequencyPenaltyInput.addEventListener("input", e => { elements.freqValue.textContent = parseFloat(e.target.value).toFixed(1); }); elements.presencePenaltyInput.addEventListener("input", e => { elements.presValue.textContent = parseFloat(e.target.value).toFixed(1); }); // System Prompt Library buttons document.querySelectorAll(".prompt-btn").forEach(btn => { btn.addEventListener("click", () => { const promptKey = btn.dataset.prompt; if (SYSTEM_PROMPTS[promptKey]) { elements.systemPromptInput.value = SYSTEM_PROMPTS[promptKey]; showNotification(`Loaded "${btn.textContent}" prompt`, "success"); } }); }); // Message input elements.messageInput.addEventListener("input", handleInputChange); elements.messageInput.addEventListener("keydown", e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (state.isStreaming) { stopStreaming(); } else { sendMessage(); } } }); // Send button - initial setup updateSendButton(); // Reset cost button const resetCostBtn = document.getElementById("resetCostBtn"); if (resetCostBtn) { resetCostBtn.addEventListener("click", () => { state.totalCost = 0; state.lastUsage = null; updateTokenCounter(); showNotification("Session cost reset", "success"); }); } // Keyboard shortcuts document.addEventListener("keydown", e => { // Ctrl/Cmd + K: New conversation if ((e.ctrlKey || e.metaKey) && e.key === "k") { e.preventDefault(); startNewConversation(); } // Ctrl/Cmd + Enter: Send message (when focused on input) if ((e.ctrlKey || e.metaKey) && e.key === "Enter" && document.activeElement === elements.messageInput) { e.preventDefault(); if (!state.isStreaming) { sendMessage(); } } // Ctrl/Cmd + B: Toggle focus mode if ((e.ctrlKey || e.metaKey) && e.key === "b") { e.preventDefault(); toggleFocusMode(); } }); // Focus mode button if (elements.focusModeBtn) { elements.focusModeBtn.addEventListener("click", toggleFocusMode); } // Scroll to bottom button if (elements.scrollToBottomBtn) { elements.scrollToBottomBtn.addEventListener("click", scrollToBottom); } // Show/hide scroll-to-bottom button based on scroll position elements.chatMessages.addEventListener("scroll", handleChatScroll); // Warn user before closing tab if streaming is in progress window.addEventListener("beforeunload", e => { if (state.isStreaming) { e.preventDefault(); e.returnValue = "A response is currently being generated. Are you sure you want to leave?"; return e.returnValue; } }); } // Debounce helper for performance let resizeTimeout = null; // Handle input change function handleInputChange() { // Debounced auto-resize textarea if (resizeTimeout) clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { elements.messageInput.style.height = "auto"; elements.messageInput.style.height = elements.messageInput.scrollHeight + "px"; }, 16); // ~60fps updateSendButton(); } // ======================================== // Scroll to Bottom & Focus Mode // ======================================== // Scroll to bottom of chat function scrollToBottom() { elements.chatMessages.scrollTo({ top: elements.chatMessages.scrollHeight, behavior: "smooth" }); } // Handle chat scroll - show/hide scroll-to-bottom button let scrollTimeout = null; function handleChatScroll() { if (scrollTimeout) clearTimeout(scrollTimeout); scrollTimeout = setTimeout(() => { const { scrollTop, scrollHeight, clientHeight } = elements.chatMessages; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; // Show button if user has scrolled up more than 200px from bottom if (elements.scrollToBottomBtn) { if (distanceFromBottom > 200) { elements.scrollToBottomBtn.classList.remove("hidden"); } else { elements.scrollToBottomBtn.classList.add("hidden"); } } }, 100); } // Toggle focus mode (hide/show sidebar on desktop) function toggleFocusMode() { document.body.classList.toggle("focus-mode"); const isFocusMode = document.body.classList.contains("focus-mode"); // Save preference localStorage.setItem("focusMode", isFocusMode ? "true" : "false"); // Show notification showNotification(isFocusMode ? "Focus mode enabled (Ctrl+B to exit)" : "Focus mode disabled", "info"); } // Restore focus mode on load function restoreFocusMode() { const savedFocusMode = localStorage.getItem("focusMode"); if (savedFocusMode === "true") { document.body.classList.add("focus-mode"); } } // Check API key function checkApiKey() { if (!state.apiKey) { showNotification("Please configure your OpenRouter API key in settings", "warning"); } } // Theme management const THEMES = ["light", "dark", "midnight"]; const THEME_LABELS = { light: "Dark Mode", dark: "Midnight", midnight: "Light Mode" }; const THEME_ICONS = { light: "moon", // Shows moon (click to go dark) dark: "sparkle", // Shows sparkle (click to go midnight) midnight: "sun" // Shows sun (click to go light) }; function applyTheme() { document.documentElement.setAttribute("data-theme", state.theme); elements.themeText.textContent = THEME_LABELS[state.theme] || "Dark Mode"; // Update icon visibility const btn = elements.themeToggle; btn.classList.remove("theme-light", "theme-dark", "theme-midnight"); btn.classList.add(`theme-${state.theme}`); } async function toggleTheme() { const currentIndex = THEMES.indexOf(state.theme); const nextIndex = (currentIndex + 1) % THEMES.length; state.theme = THEMES[nextIndex]; await settingsManager.set("theme", state.theme); applyTheme(); } // Model selection async function selectModel(modelId) { state.selectedModel = modelId; await settingsManager.set("selectedModel", modelId); updateModelSelection(); updateReasoningSection(); // Update current conversation's model in database if (conversationManager.currentConversationId) { conversationManager .updateConversationModel(conversationManager.currentConversationId, modelId) .catch(err => console.error("Failed to update conversation model:", err)); } } function updateModelSelection() { elements.modelCards.forEach(card => { card.classList.toggle("active", card.dataset.model === state.selectedModel); }); } function updateReasoningSection() { // Show reasoning section for GPT-OSS models (they support reasoning effort) const isGPTOSSModel = state.selectedModel.includes("gpt-oss"); elements.reasoningSection.style.display = isGPTOSSModel ? "block" : "none"; } // Settings modal function openSettings() { elements.settingsModal.classList.remove("hidden"); loadSettingsToUI(); } function closeSettings() { elements.settingsModal.classList.add("hidden"); } async function saveSettings() { state.apiKey = elements.apiKeyInput.value.trim(); state.systemPrompt = elements.systemPromptInput.value.trim(); state.temperature = parseFloat(elements.temperatureInput.value); state.maxTokens = parseInt(elements.maxTokensInput.value); state.topP = parseFloat(elements.topPInput.value); state.frequencyPenalty = parseFloat(elements.frequencyPenaltyInput.value); state.presencePenalty = parseFloat(elements.presencePenaltyInput.value); state.autoShowReasoning = elements.autoShowReasoningInput.checked; state.contextLimit = parseInt(elements.contextLimitInput.value); // Save to IndexedDB await settingsManager.saveAll({ apiKey: state.apiKey, systemPrompt: state.systemPrompt, temperature: state.temperature, maxTokens: state.maxTokens, topP: state.topP, frequencyPenalty: state.frequencyPenalty, presencePenalty: state.presencePenalty, autoShowReasoning: state.autoShowReasoning, contextLimit: state.contextLimit }); // Trim context if needed trimContextWindow(); closeSettings(); showNotification("Settings saved successfully", "success"); } async function clearSettings() { if (confirm("Are you sure you want to reset all settings to defaults? (Conversations will be preserved)")) { // Reset settings in IndexedDB await settingsManager.resetAll(); // Reset state to defaults Object.assign(state, DEFAULT_SETTINGS); loadSettingsToUI(); applyTheme(); updateModelSelection(); updateReasoningSection(); showNotification("Settings reset to defaults", "info"); } } // Chat functions // Chat functions function getWelcomeHTML() { return `
GPT-OSS

โšก Kai's GPT-OSS

Chat with OpenAI's powerful models via OpenRouter API

`; } function clearChat() { state.messages = []; elements.chatMessages.innerHTML = getWelcomeHTML(); } function addMessage(role, content, reasoning = null, timestamp = null, messageId = null) { // Remove welcome message if exists const welcomeMsg = elements.chatMessages.querySelector(".welcome-message"); if (welcomeMsg) { welcomeMsg.remove(); } const messageDiv = document.createElement("div"); messageDiv.className = `message ${role}`; if (messageId) messageDiv.dataset.messageId = messageId; const avatar = document.createElement("div"); avatar.className = "message-avatar"; avatar.textContent = role === "user" ? "U" : "AI"; const contentDiv = document.createElement("div"); contentDiv.className = "message-content"; const textDiv = document.createElement("div"); textDiv.className = "message-text"; // Use marked.js for assistant messages, plain text for user if (role === "assistant" && typeof marked !== "undefined") { textDiv.innerHTML = marked.parse(content); wrapTablesForScroll(textDiv); } else { textDiv.textContent = content; } contentDiv.appendChild(textDiv); // Add reasoning section if available (for assistant messages) if (role === "assistant" && reasoning) { const reasoningToggle = document.createElement("button"); reasoningToggle.className = "reasoning-toggle"; reasoningToggle.innerHTML = ` Show reasoning `; const reasoningSection = document.createElement("div"); reasoningSection.className = "reasoning-content"; reasoningSection.innerHTML = `
Reasoning Process
${escapeHtml(reasoning)}
`; // Copy reasoning button handler reasoningSection.querySelector(".copy-reasoning-btn").addEventListener("click", async () => { try { await navigator.clipboard.writeText(reasoning); showNotification("Reasoning copied", "success"); } catch (err) { showNotification("Failed to copy", "error"); } }); // Toggle reasoning visibility reasoningToggle.addEventListener("click", () => { const isVisible = reasoningSection.classList.toggle("visible"); reasoningToggle.classList.toggle("expanded"); reasoningToggle.querySelector("span").textContent = isVisible ? "Hide reasoning" : "Show reasoning"; }); // Auto-show reasoning if option is enabled if (state.autoShowReasoning) { reasoningSection.classList.add("visible"); reasoningToggle.classList.add("expanded"); reasoningToggle.querySelector("span").textContent = "Hide reasoning"; } // Insert reasoning BEFORE the message text (at the top) contentDiv.insertBefore(reasoningSection, textDiv); contentDiv.insertBefore(reasoningToggle, reasoningSection); } // Add message footer with timestamp and actions const footerDiv = document.createElement("div"); footerDiv.className = "message-footer"; const msgTime = timestamp ? new Date(timestamp) : new Date(); const timeStr = msgTime.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); // Different actions for user vs assistant messages const userActions = ` `; const assistantActions = ` `; footerDiv.innerHTML = ` ${timeStr}
${role === "user" ? userActions : assistantActions}
`; // Copy action footerDiv.querySelector('[data-action="copy"]').addEventListener("click", () => { navigator.clipboard .writeText(content) .then(() => { showNotification("Message copied", "success"); }) .catch(() => { showNotification("Failed to copy", "error"); }); }); // Delete action footerDiv.querySelector('[data-action="delete"]').addEventListener("click", async () => { await deleteMessage(messageDiv, content, role); }); // Edit action (user messages only) const editBtn = footerDiv.querySelector('[data-action="edit"]'); if (editBtn) { editBtn.addEventListener("click", () => { editUserMessage(messageDiv, content); }); } // Regenerate action (assistant messages only) const regenBtn = footerDiv.querySelector('[data-action="regenerate"]'); if (regenBtn) { regenBtn.addEventListener("click", async () => { await regenerateResponse(messageDiv); }); } contentDiv.appendChild(footerDiv); messageDiv.appendChild(avatar); messageDiv.appendChild(contentDiv); elements.chatMessages.appendChild(messageDiv); elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight; return messageDiv; } // Delete a message from UI, state, and database async function deleteMessage(messageDiv, content, role) { // Get the index of this message in the DOM to find correct state index const allMessages = Array.from(elements.chatMessages.querySelectorAll(".message")); const domIndex = allMessages.indexOf(messageDiv); // Find and remove from state.messages using index (more reliable than content matching) if (domIndex !== -1 && domIndex < state.messages.length) { state.messages.splice(domIndex, 1); } else { // Fallback to content matching if index fails const msgIndex = state.messages.findIndex(m => m.role === role && m.content === content); if (msgIndex !== -1) { state.messages.splice(msgIndex, 1); } } // Remove from database if we have a messageId const messageId = messageDiv.dataset.messageId; if (messageId) { try { await db.messages.delete(parseInt(messageId)); } catch (error) { console.error("Failed to delete message from DB:", error); } } // Animate and remove from UI messageDiv.style.transition = "opacity 0.2s, transform 0.2s"; messageDiv.style.opacity = "0"; messageDiv.style.transform = "translateX(-20px)"; setTimeout(() => { messageDiv.remove(); updateTokenCounter(); // Show welcome if no messages left if (elements.chatMessages.children.length === 0) { elements.chatMessages.innerHTML = getWelcomeHTML(); } }, 200); showNotification("Message deleted", "success"); } // State for edit modal let editMessageState = { messageDiv: null, originalContent: "" }; // Show edit message modal function showEditModal(messageDiv, originalContent) { editMessageState.messageDiv = messageDiv; editMessageState.originalContent = originalContent; const modal = document.getElementById("editMessageModal"); const textarea = document.getElementById("editMessageText"); textarea.value = originalContent; modal.classList.remove("hidden"); // Focus and select text setTimeout(() => { textarea.focus(); textarea.setSelectionRange(textarea.value.length, textarea.value.length); }, 100); } // Close edit message modal function closeEditModal() { const modal = document.getElementById("editMessageModal"); modal.classList.add("hidden"); editMessageState.messageDiv = null; editMessageState.originalContent = ""; } // Confirm edit and regenerate function confirmEditMessage() { try { const textarea = document.getElementById("editMessageText"); const newContent = textarea.value.trim(); if (!newContent || newContent === editMessageState.originalContent) { closeEditModal(); return; } const messageDiv = editMessageState.messageDiv; const originalContent = editMessageState.originalContent; closeEditModal(); // Find message index in state const msgIndex = state.messages.findIndex(m => m.role === "user" && m.content === originalContent); if (msgIndex === -1) return; // Remove this message and all following messages from state state.messages = state.messages.slice(0, msgIndex); // Remove this message and all following messages from UI let current = messageDiv; while (current) { const next = current.nextElementSibling; current.remove(); current = next; } // Delete from database (this message and all after it) const messageId = messageDiv.dataset.messageId; if (messageId && conversationManager.currentConversationId) { db.messages .where("conversationId") .equals(conversationManager.currentConversationId) .and(m => m.id >= parseInt(messageId)) .delete() .catch(e => console.error("Failed to delete messages from DB:", e)); } // Set the new message in input and send elements.messageInput.value = newContent; sendMessage(); } catch (error) { console.error("Error in confirmEditMessage:", error); showNotification("Failed to edit message", "error"); closeEditModal(); } } // Edit user message and regenerate response function editUserMessage(messageDiv, originalContent) { if (state.isStreaming) { showNotification("Cannot edit while generating", "warning"); return; } showEditModal(messageDiv, originalContent); } // Regenerate the last assistant response async function regenerateResponse(messageDiv) { if (state.isStreaming) { showNotification("Cannot regenerate while generating", "warning"); return; } // Find the content of this assistant message const textDiv = messageDiv.querySelector(".message-text"); if (!textDiv) return; // Find message index in state const msgIndex = state.messages.findLastIndex(m => m.role === "assistant"); if (msgIndex === -1) return; // Remove assistant message from state state.messages = state.messages.slice(0, msgIndex); // Remove from UI messageDiv.style.transition = "opacity 0.2s, transform 0.2s"; messageDiv.style.opacity = "0"; messageDiv.style.transform = "translateX(-20px)"; await new Promise(resolve => setTimeout(resolve, 200)); messageDiv.remove(); // Delete from database const messageId = messageDiv.dataset.messageId; if (messageId) { try { await db.messages.delete(parseInt(messageId)); } catch (error) { console.error("Failed to delete message from DB:", error); } } // Regenerate: create new streaming message and call API const { messageDiv: newMsgDiv, textDiv: newTextDiv, contentDiv } = createStreamingMessage(); state.isStreaming = true; updateSendButton(); let fullContent = ""; let reasoning = null; try { await callOpenRouterStreaming(state.messages, chunk => { if (chunk.content) { fullContent += chunk.content; updateStreamingMessage(newTextDiv, fullContent); } if (chunk.reasoning) reasoning = chunk.reasoning; if (chunk.usage) { state.lastUsage = chunk.usage; updateTokenCounter(); } }); if (fullContent) { finalizeStreamingMessage( newMsgDiv, newTextDiv, contentDiv, fullContent, reasoning, new Date().toISOString() ); state.messages.push({ role: "assistant", content: fullContent }); // Add cost for this regenerated response if (state.lastUsage) { updateTokenCounter(true); } try { await saveMessageToDB("assistant", fullContent, reasoning); } catch (error) {} trimContextWindow(); } } catch (error) { if (error.name === "AbortError") { if (fullContent) { finalizeStreamingMessage( newMsgDiv, newTextDiv, contentDiv, fullContent + "\n\n*[Generation stopped]*", reasoning, new Date().toISOString() ); state.messages.push({ role: "assistant", content: fullContent }); } else { newMsgDiv.remove(); } } else { newMsgDiv.remove(); showNotification(error.message || "Failed to regenerate", "error"); } } finally { state.isStreaming = false; state.abortController = null; updateSendButton(); } } function addLoadingMessage() { const messageDiv = document.createElement("div"); messageDiv.className = "message assistant loading"; messageDiv.id = "loading-message"; const avatar = document.createElement("div"); avatar.className = "message-avatar"; avatar.textContent = "AI"; const contentDiv = document.createElement("div"); contentDiv.className = "message-content"; const loadingDiv = document.createElement("div"); loadingDiv.className = "message-loading"; loadingDiv.innerHTML = '
'; contentDiv.appendChild(loadingDiv); messageDiv.appendChild(avatar); messageDiv.appendChild(contentDiv); elements.chatMessages.appendChild(messageDiv); elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight; return messageDiv; } function removeLoadingMessage() { const loadingMsg = document.getElementById("loading-message"); if (loadingMsg) { loadingMsg.remove(); } } // Create streaming message element function createStreamingMessage() { const welcomeMsg = elements.chatMessages.querySelector(".welcome-message"); if (welcomeMsg) welcomeMsg.remove(); const messageDiv = document.createElement("div"); messageDiv.className = "message assistant streaming"; messageDiv.id = "streaming-message"; const avatar = document.createElement("div"); avatar.className = "message-avatar"; avatar.textContent = "AI"; const contentDiv = document.createElement("div"); contentDiv.className = "message-content"; const textDiv = document.createElement("div"); textDiv.className = "message-text"; textDiv.innerHTML = ''; contentDiv.appendChild(textDiv); messageDiv.appendChild(avatar); messageDiv.appendChild(contentDiv); elements.chatMessages.appendChild(messageDiv); elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight; return { messageDiv, textDiv, contentDiv }; } // Update streaming message content function updateStreamingMessage(textDiv, content) { if (typeof marked !== "undefined") { textDiv.innerHTML = marked.parse(content) + ''; } else { textDiv.textContent = content; } elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight; } // Finalize streaming message (convert to normal message) function finalizeStreamingMessage(messageDiv, textDiv, contentDiv, content, reasoning, timestamp) { messageDiv.classList.remove("streaming"); messageDiv.id = ""; // Remove cursor const cursor = textDiv.querySelector(".streaming-cursor"); if (cursor) cursor.remove(); // Re-render with marked if (typeof marked !== "undefined") { textDiv.innerHTML = marked.parse(content); wrapTablesForScroll(textDiv); } // Add reasoning if available if (reasoning) { const reasoningToggle = document.createElement("button"); reasoningToggle.className = "reasoning-toggle"; reasoningToggle.innerHTML = ` Show reasoning `; const reasoningSection = document.createElement("div"); reasoningSection.className = "reasoning-content"; reasoningSection.innerHTML = `
Reasoning Process
${escapeHtml(reasoning)}
`; // Copy reasoning button handler reasoningSection.querySelector(".copy-reasoning-btn").addEventListener("click", async () => { try { await navigator.clipboard.writeText(reasoning); showNotification("Reasoning copied", "success"); } catch (err) { showNotification("Failed to copy", "error"); } }); reasoningToggle.addEventListener("click", () => { const isVisible = reasoningSection.classList.toggle("visible"); reasoningToggle.classList.toggle("expanded"); reasoningToggle.querySelector("span").textContent = isVisible ? "Hide reasoning" : "Show reasoning"; }); if (state.autoShowReasoning) { reasoningSection.classList.add("visible"); reasoningToggle.classList.add("expanded"); reasoningToggle.querySelector("span").textContent = "Hide reasoning"; } contentDiv.insertBefore(reasoningSection, textDiv); contentDiv.insertBefore(reasoningToggle, reasoningSection); } // Add footer const footerDiv = document.createElement("div"); footerDiv.className = "message-footer"; const msgTime = timestamp ? new Date(timestamp) : new Date(); const timeStr = msgTime.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); footerDiv.innerHTML = ` ${timeStr}
`; // Regenerate button handler footerDiv.querySelector('[data-action="regenerate"]').addEventListener("click", async () => { await regenerateResponse(messageDiv); }); footerDiv.querySelector('[data-action="copy"]').addEventListener("click", () => { navigator.clipboard .writeText(content) .then(() => { showNotification("Message copied", "success"); }) .catch(() => { showNotification("Failed to copy", "error"); }); }); footerDiv.querySelector('[data-action="delete"]').addEventListener("click", async () => { await deleteMessage(messageDiv, content, "assistant"); }); contentDiv.appendChild(footerDiv); } // Stop streaming function stopStreaming() { if (state.abortController) { state.abortController.abort(); state.abortController = null; } } async function sendMessage() { const message = elements.messageInput.value.trim(); if (!message || state.isStreaming) return; if (!state.apiKey) { showNotification("Please configure your API key first", "error"); openSettings(); return; } // Add user message addMessage("user", message); state.messages.push({ role: "user", content: message }); updateMessageCounter(); // Save user message to database try { await saveMessageToDB("user", message); } catch (error) { // Continue anyway - message is in UI and state console.warn("User message not saved to DB, but continuing..."); } // Clear input elements.messageInput.value = ""; elements.messageInput.style.height = "auto"; handleInputChange(); // Create streaming message const { messageDiv, textDiv, contentDiv } = createStreamingMessage(); state.isStreaming = true; updateSendButton(); let fullContent = ""; let reasoning = null; try { await callOpenRouterStreaming(state.messages, chunk => { // Handle content chunks if (chunk.content) { fullContent += chunk.content; updateStreamingMessage(textDiv, fullContent); } // Handle reasoning if (chunk.reasoning) { reasoning = chunk.reasoning; } // Handle usage stats (display only, don't add cost yet) if (chunk.usage) { state.lastUsage = chunk.usage; updateTokenCounter(false); } }); // Finalize message if (fullContent) { finalizeStreamingMessage(messageDiv, textDiv, contentDiv, fullContent, reasoning, new Date().toISOString()); state.messages.push({ role: "assistant", content: fullContent }); // Now add the cost for this complete response if (state.lastUsage) { updateTokenCounter(true); } // Save to database try { await saveMessageToDB("assistant", fullContent, reasoning); } catch (error) { console.warn("Assistant message not saved to DB"); } trimContextWindow(); } } catch (error) { if (error.name === "AbortError") { // User cancelled - keep partial content if any if (fullContent) { finalizeStreamingMessage( messageDiv, textDiv, contentDiv, fullContent + "\n\n*[Generation stopped]*", reasoning, new Date().toISOString() ); state.messages.push({ role: "assistant", content: fullContent }); try { await saveMessageToDB("assistant", fullContent, reasoning); } catch (e) {} } else { messageDiv.remove(); } showNotification("Generation stopped", "info"); } else { messageDiv.remove(); showNotification(error.message || "Failed to get response", "error"); console.error("Error:", error); } } finally { state.isStreaming = false; state.abortController = null; updateSendButton(); } } // Update send button state (send vs stop) function updateSendButton() { const hasText = elements.messageInput.value.trim().length > 0; if (state.isStreaming) { elements.sendBtn.innerHTML = ` `; elements.sendBtn.disabled = false; elements.sendBtn.classList.add("stop-btn"); elements.sendBtn.onclick = stopStreaming; } else { elements.sendBtn.innerHTML = ` `; elements.sendBtn.disabled = !hasText; elements.sendBtn.classList.remove("stop-btn"); elements.sendBtn.onclick = sendMessage; } } // Update token counter display function updateTokenCounter(addCost = false) { const sessionCostEl = document.getElementById("sessionCostDisplay"); const tokenUsageEl = document.getElementById("tokenUsageDisplay"); if (state.lastUsage) { const input = state.lastUsage.prompt_tokens || 0; const output = state.lastUsage.completion_tokens || 0; // Only add cost when explicitly requested (after a complete response) if (addCost) { const lastCost = calculateCost(state.lastUsage, state.selectedModel); state.totalCost += lastCost; } const costStr = state.totalCost < 0.01 ? `${(state.totalCost * 100).toFixed(2)}ยข` : `$${state.totalCost.toFixed(4)}`; // Update sidebar cost display if (sessionCostEl) { sessionCostEl.textContent = state.totalCost < 0.01 ? `${(state.totalCost * 100).toFixed(2)}ยข` : `$${state.totalCost.toFixed(4)}`; } if (tokenUsageEl) { tokenUsageEl.textContent = `Last: ${input} in โ†’ ${output} out`; } // Update message counter with compact cost badge elements.messageCounter.innerHTML = ` ๐Ÿ“Š ${input}โ†’${output} ${costStr} `; } else { const messagePairs = Math.floor(state.messages.length / 2); elements.messageCounter.textContent = `Messages: ${messagePairs} / ${state.contextLimit} pairs`; } } async function callOpenRouterStreaming(messages, onChunk, retryCount = 0) { const MAX_RETRIES = 2; const apiUrl = "https://openrouter.ai/api/v1/chat/completions"; // Prepare messages with system prompt const requestMessages = [{ role: "system", content: state.systemPrompt }, ...messages]; const requestBody = { model: state.selectedModel, messages: requestMessages, temperature: state.temperature, max_tokens: state.maxTokens, top_p: state.topP, frequency_penalty: state.frequencyPenalty, presence_penalty: state.presencePenalty, stream: true }; // Add reasoning config for GPT-OSS models if (state.selectedModel.includes("gpt-oss")) { requestBody.reasoning = { effort: state.reasoningEffort }; } state.abortController = new AbortController(); let response; try { response = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${state.apiKey}`, "HTTP-Referer": window.location.origin, "X-Title": "GPT-OSS Demo" }, body: JSON.stringify(requestBody), signal: state.abortController.signal }); } catch (error) { // Network error (offline, DNS failure, CORS, etc.) if (error.name === "AbortError") throw error; if (retryCount < MAX_RETRIES) { showNotification(`Connection failed. Retrying... (${retryCount + 1}/${MAX_RETRIES})`, "warning"); await new Promise(r => setTimeout(r, 1000 * (retryCount + 1))); // Exponential backoff return callOpenRouterStreaming(messages, onChunk, retryCount + 1); } // Final failure if (!navigator.onLine) { throw new Error("You are offline. Please check your internet connection."); } throw new Error("Network error. Please check your connection and try again."); } if (!response.ok) { let errorMessage = `HTTP error! status: ${response.status}`; try { const error = await response.json(); errorMessage = error.error?.message || errorMessage; // Provide helpful messages for common errors if (response.status === 401) { errorMessage = "Invalid API key. Please check your settings."; } else if (response.status === 429) { errorMessage = "Rate limit exceeded. Please wait a moment and try again."; } else if (response.status === 503) { errorMessage = "Service temporarily unavailable. Please try again later."; } } catch (e) { // Failed to parse error response } throw new Error(errorMessage); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let reasoning = null; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (line.startsWith("data: ")) { const data = line.slice(6); if (data === "[DONE]") { if (reasoning) onChunk({ reasoning }); return; } try { const parsed = JSON.parse(data); const delta = parsed.choices?.[0]?.delta; if (delta?.content) { onChunk({ content: delta.content }); } // Handle reasoning from delta - prefer reasoning_details over reasoning // to avoid duplication (some models send both) if (delta?.reasoning_details) { const texts = delta.reasoning_details .filter(r => r.type === "reasoning.text" || r.type === "reasoning.summary") .map(r => r.text || r.summary) .filter(Boolean); if (texts.length) { reasoning = (reasoning || "") + texts.join("\n"); } } else if (delta?.reasoning) { // Fallback to reasoning only if reasoning_details is not present reasoning = (reasoning || "") + delta.reasoning; } // Usage stats (usually in final chunk) if (parsed.usage) { onChunk({ usage: parsed.usage }); } } catch (e) { // Skip invalid JSON } } } } if (reasoning) onChunk({ reasoning }); } // Notification system function showNotification(message, type = "info") { // Remove existing notifications const existing = document.querySelector(".notification"); if (existing) existing.remove(); const notification = document.createElement("div"); notification.className = `notification notification-${type}`; notification.style.cssText = ` position: fixed; top: 20px; right: 20px; padding: 16px 20px; background-color: ${ type === "error" ? "var(--color-danger)" : type === "success" ? "var(--color-success)" : type === "warning" ? "#ff9e6c" : "var(--color-primary)" }; color: white; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 10000; animation: slideInRight 0.3s ease; max-width: 400px; font-size: 14px; font-weight: 500; `; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { notification.style.animation = "slideOutRight 0.3s ease"; setTimeout(() => notification.remove(), 300); }, 3000); } // Add CSS for notifications const style = document.createElement("style"); style.textContent = ` @keyframes slideInRight { from { opacity: 0; transform: translateX(100px); } to { opacity: 1; transform: translateX(0); } } @keyframes slideOutRight { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(100px); } } `; document.head.appendChild(style); // ======================================== // About Modal โ€” Made with ๐Ÿ’™ by Kai โšก // ======================================== function setupAboutModal() { const aboutModal = document.getElementById("aboutModal"); const closeAboutModal = document.getElementById("closeAboutModal"); const btnAbout = document.getElementById("btn-about"); if (!aboutModal) return; function openAboutModal() { aboutModal.classList.remove("hidden"); document.body.style.overflow = "hidden"; } function closeAboutModalFn() { aboutModal.classList.add("hidden"); document.body.style.overflow = ""; } // Event listeners if (btnAbout) btnAbout.addEventListener("click", e => { e.preventDefault(); openAboutModal(); }); if (closeAboutModal) closeAboutModal.addEventListener("click", closeAboutModalFn); // Close on overlay click aboutModal.addEventListener("click", e => { if (e.target === aboutModal) closeAboutModalFn(); }); // Close on Escape key document.addEventListener("keydown", e => { if (e.key === "Escape" && !aboutModal.classList.contains("hidden")) { closeAboutModalFn(); } }); } // Initialize app when DOM is ready if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { init(); setupAboutModal(); }); } else { init(); setupAboutModal(); }