// 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 `
โก 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 = `
${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 = `
${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();
}