| |
|
| | 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",
|
| |
|
| |
|
| | messages: [],
|
| | isStreaming: false,
|
| | abortController: null,
|
| | lastUsage: null,
|
| | totalCost: 0,
|
| | isInitialLoad: true
|
| | };
|
| |
|
| |
|
| | const MODEL_PRICING = {
|
| | "openai/gpt-oss-20b": { input: 1.5, output: 6.0 },
|
| | "openai/gpt-oss-120b": { input: 3.0, output: 12.0 }
|
| | };
|
| |
|
| |
|
| | 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;
|
| | }
|
| |
|
| |
|
| | 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"),
|
| |
|
| | 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"),
|
| |
|
| | scrollToBottomBtn: document.getElementById("scrollToBottomBtn"),
|
| | focusModeBtn: document.getElementById("focusModeBtn")
|
| | };
|
| |
|
| |
|
| |
|
| |
|
| | function trimContextWindow() {
|
| |
|
| | const maxMessages = state.contextLimit * 2;
|
| | if (state.messages.length > maxMessages) {
|
| | state.messages = state.messages.slice(-maxMessages);
|
| | }
|
| | updateMessageCounter();
|
| | }
|
| |
|
| |
|
| | function updateMessageCounter() {
|
| | const messagePairs = Math.floor(state.messages.length / 2);
|
| | const maxPairs = state.contextLimit;
|
| | elements.messageCounter.textContent = `Messages: ${messagePairs} / ${maxPairs} pairs`;
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | async function startNewConversation() {
|
| | try {
|
| |
|
| | state.totalCost = 0;
|
| | state.lastUsage = null;
|
| | updateTokenCounter();
|
| |
|
| |
|
| | 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);
|
| | }
|
| | }
|
| |
|
| |
|
| | async function importConversation() {
|
| | try {
|
| | const data = await showImportFilePicker();
|
| | if (!data) return;
|
| |
|
| | 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);
|
| | }
|
| | }
|
| |
|
| |
|
| | function loadConversationIntoUI(conversation, messages) {
|
| |
|
| | state.totalCost = 0;
|
| | state.lastUsage = null;
|
| | updateTokenCounter(false);
|
| |
|
| |
|
| | elements.chatMessages.innerHTML = "";
|
| |
|
| | if (messages.length === 0) {
|
| | elements.chatMessages.innerHTML = getWelcomeHTML();
|
| | state.messages = [];
|
| | } else {
|
| |
|
| | 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
|
| | }));
|
| | }
|
| |
|
| |
|
| | if (conversation.model !== state.selectedModel) {
|
| | selectModel(conversation.model);
|
| | }
|
| |
|
| | updateMessageCounter();
|
| |
|
| |
|
| | conversationsUI.refresh();
|
| |
|
| |
|
| | if (!state.isInitialLoad && messages.length > 0) {
|
| | const msgCount = Math.floor(messages.length / 2);
|
| | showNotification(`Loaded "${conversation.title}" (${msgCount} messages)`, "info");
|
| | }
|
| |
|
| |
|
| | state.isInitialLoad = false;
|
| | }
|
| |
|
| |
|
| | async function saveMessageToDB(role, content, reasoning = null) {
|
| | if (!conversationManager.currentConversationId) {
|
| | await startNewConversation();
|
| | }
|
| |
|
| | try {
|
| | await conversationManager.addMessage(conversationManager.currentConversationId, role, content, reasoning);
|
| |
|
| |
|
| | 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;
|
| | }
|
| | }
|
| |
|
| |
|
| | async function init() {
|
| | await loadSettingsFromDB();
|
| | setupEventListeners();
|
| | applyTheme();
|
| | updateModelSelection();
|
| | updateReasoningSection();
|
| | checkApiKey();
|
| | restoreFocusMode();
|
| | conversationsUI.init();
|
| | await initializeConversations();
|
| | }
|
| |
|
| |
|
| | async function loadSettingsFromDB() {
|
| | const settings = await settingsManager.getAll();
|
| | Object.assign(state, settings);
|
| | loadSettingsToUI();
|
| | }
|
| |
|
| |
|
| | 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;
|
| |
|
| |
|
| | const reasoningRadio = document.querySelector(`input[name="reasoning"][value="${state.reasoningEffort}"]`);
|
| | if (reasoningRadio) reasoningRadio.checked = true;
|
| | }
|
| |
|
| |
|
| | async function initializeConversations() {
|
| |
|
| | conversationManager.onConversationChange = (conversation, messages) => {
|
| | loadConversationIntoUI(conversation, messages);
|
| | };
|
| |
|
| |
|
| | const allConversations = await conversationManager.getAllConversations();
|
| |
|
| | if (allConversations.length === 0) {
|
| | await startNewConversation();
|
| | } else {
|
| | conversationManager.currentConversationId = allConversations[0].id;
|
| | await conversationManager.loadConversation(allConversations[0].id);
|
| | }
|
| | }
|
| |
|
| |
|
| | function setupEventListeners() {
|
| |
|
| | elements.newChatBtn.addEventListener("click", async () => {
|
| | await startNewConversation();
|
| | });
|
| |
|
| |
|
| | document.getElementById("importConversationBtn").addEventListener("click", async () => {
|
| | await importConversation();
|
| | });
|
| |
|
| |
|
| | elements.settingsBtn.addEventListener("click", openSettings);
|
| | elements.closeModal.addEventListener("click", closeSettings);
|
| | elements.saveSettings.addEventListener("click", saveSettings);
|
| | elements.clearSettings.addEventListener("click", clearSettings);
|
| |
|
| |
|
| | document.getElementById("closeEditModal").addEventListener("click", closeEditModal);
|
| | document.getElementById("cancelEditMessage").addEventListener("click", closeEditModal);
|
| | document.getElementById("confirmEditMessage").addEventListener("click", confirmEditMessage);
|
| |
|
| |
|
| | document.getElementById("editMessageModal").addEventListener("click", e => {
|
| | if (e.target.id === "editMessageModal") {
|
| | closeEditModal();
|
| | }
|
| | });
|
| |
|
| |
|
| | document.getElementById("editMessageText").addEventListener("keydown", e => {
|
| | if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
| | e.preventDefault();
|
| | confirmEditMessage();
|
| | }
|
| | if (e.key === "Escape") {
|
| | closeEditModal();
|
| | }
|
| | });
|
| |
|
| |
|
| | elements.settingsModal.addEventListener("click", e => {
|
| | if (e.target === elements.settingsModal || e.target.classList.contains("modal-backdrop")) {
|
| | closeSettings();
|
| | }
|
| | });
|
| |
|
| |
|
| | elements.themeToggle.addEventListener("click", toggleTheme);
|
| |
|
| |
|
| | elements.toggleSidebar.addEventListener("click", e => {
|
| | e.stopPropagation();
|
| | const isOpen = elements.sidebar.classList.toggle("open");
|
| | elements.sidebarOverlay.classList.toggle("active", isOpen);
|
| |
|
| |
|
| | if (isOpen) {
|
| | document.body.style.overflow = "hidden";
|
| | } else {
|
| | document.body.style.overflow = "";
|
| | }
|
| | });
|
| |
|
| |
|
| | elements.sidebarOverlay.addEventListener("click", () => {
|
| | elements.sidebar.classList.remove("open");
|
| | elements.sidebarOverlay.classList.remove("active");
|
| | document.body.style.overflow = "";
|
| | });
|
| |
|
| |
|
| | document.addEventListener("click", e => {
|
| | const sidebar = elements.sidebar;
|
| | const toggleBtn = elements.toggleSidebar;
|
| |
|
| |
|
| | 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 = "";
|
| | }
|
| | });
|
| |
|
| |
|
| | elements.sidebar.addEventListener("click", e => {
|
| | e.stopPropagation();
|
| | });
|
| |
|
| |
|
| | 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 = "";
|
| | }
|
| | });
|
| |
|
| |
|
| | elements.modelCards.forEach(card => {
|
| | card.addEventListener("click", () => {
|
| | selectModel(card.dataset.model);
|
| | });
|
| | });
|
| |
|
| |
|
| | document.querySelectorAll('input[name="reasoning"]').forEach(radio => {
|
| | radio.addEventListener("change", async e => {
|
| | state.reasoningEffort = e.target.value;
|
| | await settingsManager.set("reasoningEffort", state.reasoningEffort);
|
| | });
|
| | });
|
| |
|
| |
|
| | 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");
|
| | }
|
| | });
|
| | });
|
| |
|
| |
|
| | 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);
|
| | });
|
| |
|
| |
|
| | 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");
|
| | }
|
| | });
|
| | });
|
| |
|
| |
|
| | elements.messageInput.addEventListener("input", handleInputChange);
|
| | elements.messageInput.addEventListener("keydown", e => {
|
| | if (e.key === "Enter" && !e.shiftKey) {
|
| | e.preventDefault();
|
| | if (state.isStreaming) {
|
| | stopStreaming();
|
| | } else {
|
| | sendMessage();
|
| | }
|
| | }
|
| | });
|
| |
|
| |
|
| | updateSendButton();
|
| |
|
| |
|
| | const resetCostBtn = document.getElementById("resetCostBtn");
|
| | if (resetCostBtn) {
|
| | resetCostBtn.addEventListener("click", () => {
|
| | state.totalCost = 0;
|
| | state.lastUsage = null;
|
| | updateTokenCounter();
|
| | showNotification("Session cost reset", "success");
|
| | });
|
| | }
|
| |
|
| |
|
| | document.addEventListener("keydown", e => {
|
| |
|
| | if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
| | e.preventDefault();
|
| | startNewConversation();
|
| | }
|
| |
|
| | if ((e.ctrlKey || e.metaKey) && e.key === "Enter" && document.activeElement === elements.messageInput) {
|
| | e.preventDefault();
|
| | if (!state.isStreaming) {
|
| | sendMessage();
|
| | }
|
| | }
|
| |
|
| | if ((e.ctrlKey || e.metaKey) && e.key === "b") {
|
| | e.preventDefault();
|
| | toggleFocusMode();
|
| | }
|
| | });
|
| |
|
| |
|
| | if (elements.focusModeBtn) {
|
| | elements.focusModeBtn.addEventListener("click", toggleFocusMode);
|
| | }
|
| |
|
| |
|
| | if (elements.scrollToBottomBtn) {
|
| | elements.scrollToBottomBtn.addEventListener("click", scrollToBottom);
|
| | }
|
| |
|
| |
|
| | elements.chatMessages.addEventListener("scroll", handleChatScroll);
|
| |
|
| |
|
| | 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;
|
| | }
|
| | });
|
| | }
|
| |
|
| |
|
| | let resizeTimeout = null;
|
| |
|
| |
|
| | function handleInputChange() {
|
| |
|
| | if (resizeTimeout) clearTimeout(resizeTimeout);
|
| | resizeTimeout = setTimeout(() => {
|
| | elements.messageInput.style.height = "auto";
|
| | elements.messageInput.style.height = elements.messageInput.scrollHeight + "px";
|
| | }, 16);
|
| |
|
| | updateSendButton();
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | function scrollToBottom() {
|
| | elements.chatMessages.scrollTo({
|
| | top: elements.chatMessages.scrollHeight,
|
| | behavior: "smooth"
|
| | });
|
| | }
|
| |
|
| |
|
| | let scrollTimeout = null;
|
| | function handleChatScroll() {
|
| | if (scrollTimeout) clearTimeout(scrollTimeout);
|
| | scrollTimeout = setTimeout(() => {
|
| | const { scrollTop, scrollHeight, clientHeight } = elements.chatMessages;
|
| | const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
| |
|
| |
|
| | if (elements.scrollToBottomBtn) {
|
| | if (distanceFromBottom > 200) {
|
| | elements.scrollToBottomBtn.classList.remove("hidden");
|
| | } else {
|
| | elements.scrollToBottomBtn.classList.add("hidden");
|
| | }
|
| | }
|
| | }, 100);
|
| | }
|
| |
|
| |
|
| | function toggleFocusMode() {
|
| | document.body.classList.toggle("focus-mode");
|
| | const isFocusMode = document.body.classList.contains("focus-mode");
|
| |
|
| |
|
| | localStorage.setItem("focusMode", isFocusMode ? "true" : "false");
|
| |
|
| |
|
| | showNotification(isFocusMode ? "Focus mode enabled (Ctrl+B to exit)" : "Focus mode disabled", "info");
|
| | }
|
| |
|
| |
|
| | function restoreFocusMode() {
|
| | const savedFocusMode = localStorage.getItem("focusMode");
|
| | if (savedFocusMode === "true") {
|
| | document.body.classList.add("focus-mode");
|
| | }
|
| | }
|
| |
|
| |
|
| | function checkApiKey() {
|
| | if (!state.apiKey) {
|
| | showNotification("Please configure your OpenRouter API key in settings", "warning");
|
| | }
|
| | }
|
| |
|
| |
|
| | const THEMES = ["light", "dark", "midnight"];
|
| | const THEME_LABELS = {
|
| | light: "Dark Mode",
|
| | dark: "Midnight",
|
| | midnight: "Light Mode"
|
| | };
|
| | const THEME_ICONS = {
|
| | light: "moon",
|
| | dark: "sparkle",
|
| | midnight: "sun"
|
| | };
|
| |
|
| | function applyTheme() {
|
| | document.documentElement.setAttribute("data-theme", state.theme);
|
| | elements.themeText.textContent = THEME_LABELS[state.theme] || "Dark Mode";
|
| |
|
| |
|
| | 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();
|
| | }
|
| |
|
| |
|
| | async function selectModel(modelId) {
|
| | state.selectedModel = modelId;
|
| | await settingsManager.set("selectedModel", modelId);
|
| | updateModelSelection();
|
| | updateReasoningSection();
|
| |
|
| |
|
| | 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() {
|
| |
|
| | const isGPTOSSModel = state.selectedModel.includes("gpt-oss");
|
| | elements.reasoningSection.style.display = isGPTOSSModel ? "block" : "none";
|
| | }
|
| |
|
| |
|
| | 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);
|
| |
|
| |
|
| | 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
|
| | });
|
| |
|
| |
|
| | 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)")) {
|
| |
|
| | await settingsManager.resetAll();
|
| |
|
| |
|
| | Object.assign(state, DEFAULT_SETTINGS);
|
| |
|
| | loadSettingsToUI();
|
| | applyTheme();
|
| | updateModelSelection();
|
| | updateReasoningSection();
|
| | showNotification("Settings reset to defaults", "info");
|
| | }
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| | function getWelcomeHTML() {
|
| | return `
|
| | <div class="welcome-message">
|
| | <img src="assets/images/open-models-gpt-oss-16x9.jpg" alt="GPT-OSS" class="welcome-image">
|
| | <h2 class="welcome-title">⚡ Kai's GPT-OSS</h2>
|
| | <p class="welcome-text">Chat with OpenAI's powerful models via OpenRouter API</p>
|
| | </div>
|
| | `;
|
| | }
|
| |
|
| | function clearChat() {
|
| | state.messages = [];
|
| | elements.chatMessages.innerHTML = getWelcomeHTML();
|
| | }
|
| |
|
| | function addMessage(role, content, reasoning = null, timestamp = null, messageId = null) {
|
| |
|
| | 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";
|
| |
|
| |
|
| | if (role === "assistant" && typeof marked !== "undefined") {
|
| | textDiv.innerHTML = marked.parse(content);
|
| | wrapTablesForScroll(textDiv);
|
| | } else {
|
| | textDiv.textContent = content;
|
| | }
|
| |
|
| | contentDiv.appendChild(textDiv);
|
| |
|
| |
|
| | if (role === "assistant" && reasoning) {
|
| | const reasoningToggle = document.createElement("button");
|
| | reasoningToggle.className = "reasoning-toggle";
|
| | reasoningToggle.innerHTML = `
|
| | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| | <polyline points="6 9 12 15 18 9"></polyline>
|
| | </svg>
|
| | <span>Show reasoning</span>
|
| | `;
|
| |
|
| | const reasoningSection = document.createElement("div");
|
| | reasoningSection.className = "reasoning-content";
|
| | reasoningSection.innerHTML = `
|
| | <div class="reasoning-header">
|
| | <div class="reasoning-label">
|
| | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| | <circle cx="12" cy="12" r="10"></circle>
|
| | <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
|
| | <line x1="12" y1="17" x2="12.01" y2="17"></line>
|
| | </svg>
|
| | Reasoning Process
|
| | </div>
|
| | <button class="copy-reasoning-btn" title="Copy reasoning">
|
| | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| | <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
| | <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
| | </svg>
|
| | Copy
|
| | </button>
|
| | </div>
|
| | <div class="reasoning-text">${escapeHtml(reasoning)}</div>
|
| | `;
|
| |
|
| |
|
| | 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);
|
| | }
|
| |
|
| |
|
| | 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"
|
| | });
|
| |
|
| |
|
| | const userActions = `
|
| | <button class="msg-action-btn" data-action="edit" title="Edit">
|
| | <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| | <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
| | <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
| | </svg>
|
| | </button>
|
| | `;
|
| |
|
| | const assistantActions = `
|
| | <button class="msg-action-btn" data-action="regenerate" title="Regenerate">
|
| | <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| | <polyline points="23 4 23 10 17 10"></polyline>
|
| | <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
| | </svg>
|
| | </button>
|
| | `;
|
| |
|
| | footerDiv.innerHTML = `
|
| | <span class="message-time">${timeStr}</span>
|
| | <div class="message-actions">
|
| | ${role === "user" ? userActions : assistantActions}
|
| | <button class="msg-action-btn" data-action="copy" title="Copy">
|
| | <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| | <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
| | <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
| | </svg>
|
| | </button>
|
| | <button class="msg-action-btn msg-action-delete" data-action="delete" title="Delete">
|
| | <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| | <polyline points="3 6 5 6 21 6"></polyline>
|
| | <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
| | </svg>
|
| | </button>
|
| | </div>
|
| | `;
|
| |
|
| |
|
| | 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, role);
|
| | });
|
| |
|
| |
|
| | const editBtn = footerDiv.querySelector('[data-action="edit"]');
|
| | if (editBtn) {
|
| | editBtn.addEventListener("click", () => {
|
| | editUserMessage(messageDiv, content);
|
| | });
|
| | }
|
| |
|
| |
|
| | 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;
|
| | }
|
| |
|
| |
|
| | async function deleteMessage(messageDiv, content, role) {
|
| |
|
| | const allMessages = Array.from(elements.chatMessages.querySelectorAll(".message"));
|
| | const domIndex = allMessages.indexOf(messageDiv);
|
| |
|
| |
|
| | if (domIndex !== -1 && domIndex < state.messages.length) {
|
| | state.messages.splice(domIndex, 1);
|
| | } else {
|
| |
|
| | const msgIndex = state.messages.findIndex(m => m.role === role && m.content === content);
|
| | if (msgIndex !== -1) {
|
| | state.messages.splice(msgIndex, 1);
|
| | }
|
| | }
|
| |
|
| |
|
| | 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);
|
| | }
|
| | }
|
| |
|
| |
|
| | messageDiv.style.transition = "opacity 0.2s, transform 0.2s";
|
| | messageDiv.style.opacity = "0";
|
| | messageDiv.style.transform = "translateX(-20px)";
|
| | setTimeout(() => {
|
| | messageDiv.remove();
|
| | updateTokenCounter();
|
| |
|
| |
|
| | if (elements.chatMessages.children.length === 0) {
|
| | elements.chatMessages.innerHTML = getWelcomeHTML();
|
| | }
|
| | }, 200);
|
| |
|
| | showNotification("Message deleted", "success");
|
| | }
|
| |
|
| |
|
| | let editMessageState = {
|
| | messageDiv: null,
|
| | originalContent: ""
|
| | };
|
| |
|
| |
|
| | 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");
|
| |
|
| |
|
| | setTimeout(() => {
|
| | textarea.focus();
|
| | textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
| | }, 100);
|
| | }
|
| |
|
| |
|
| | function closeEditModal() {
|
| | const modal = document.getElementById("editMessageModal");
|
| | modal.classList.add("hidden");
|
| | editMessageState.messageDiv = null;
|
| | editMessageState.originalContent = "";
|
| | }
|
| |
|
| |
|
| | 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();
|
| |
|
| |
|
| | const msgIndex = state.messages.findIndex(m => m.role === "user" && m.content === originalContent);
|
| | if (msgIndex === -1) return;
|
| |
|
| |
|
| | state.messages = state.messages.slice(0, msgIndex);
|
| |
|
| |
|
| | let current = messageDiv;
|
| | while (current) {
|
| | const next = current.nextElementSibling;
|
| | current.remove();
|
| | current = next;
|
| | }
|
| |
|
| |
|
| | 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));
|
| | }
|
| |
|
| |
|
| | elements.messageInput.value = newContent;
|
| | sendMessage();
|
| | } catch (error) {
|
| | console.error("Error in confirmEditMessage:", error);
|
| | showNotification("Failed to edit message", "error");
|
| | closeEditModal();
|
| | }
|
| | }
|
| |
|
| |
|
| | function editUserMessage(messageDiv, originalContent) {
|
| | if (state.isStreaming) {
|
| | showNotification("Cannot edit while generating", "warning");
|
| | return;
|
| | }
|
| |
|
| | showEditModal(messageDiv, originalContent);
|
| | }
|
| |
|
| |
|
| | async function regenerateResponse(messageDiv) {
|
| | if (state.isStreaming) {
|
| | showNotification("Cannot regenerate while generating", "warning");
|
| | return;
|
| | }
|
| |
|
| |
|
| | const textDiv = messageDiv.querySelector(".message-text");
|
| | if (!textDiv) return;
|
| |
|
| |
|
| | const msgIndex = state.messages.findLastIndex(m => m.role === "assistant");
|
| | if (msgIndex === -1) return;
|
| |
|
| |
|
| | state.messages = state.messages.slice(0, msgIndex);
|
| |
|
| |
|
| | 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();
|
| |
|
| |
|
| | 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);
|
| | }
|
| | }
|
| |
|
| |
|
| | 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 });
|
| |
|
| |
|
| | 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 =
|
| | '<div class="loading-dot"></div><div class="loading-dot"></div><div class="loading-dot"></div>';
|
| |
|
| | 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();
|
| | }
|
| | }
|
| |
|
| |
|
| | 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 = '<span class="streaming-cursor"></span>';
|
| |
|
| | contentDiv.appendChild(textDiv);
|
| | messageDiv.appendChild(avatar);
|
| | messageDiv.appendChild(contentDiv);
|
| |
|
| | elements.chatMessages.appendChild(messageDiv);
|
| | elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight;
|
| |
|
| | return { messageDiv, textDiv, contentDiv };
|
| | }
|
| |
|
| |
|
| | function updateStreamingMessage(textDiv, content) {
|
| | if (typeof marked !== "undefined") {
|
| | textDiv.innerHTML = marked.parse(content) + '<span class="streaming-cursor"></span>';
|
| | } else {
|
| | textDiv.textContent = content;
|
| | }
|
| | elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight;
|
| | }
|
| |
|
| |
|
| | function finalizeStreamingMessage(messageDiv, textDiv, contentDiv, content, reasoning, timestamp) {
|
| | messageDiv.classList.remove("streaming");
|
| | messageDiv.id = "";
|
| |
|
| |
|
| | const cursor = textDiv.querySelector(".streaming-cursor");
|
| | if (cursor) cursor.remove();
|
| |
|
| |
|
| | if (typeof marked !== "undefined") {
|
| | textDiv.innerHTML = marked.parse(content);
|
| | wrapTablesForScroll(textDiv);
|
| | }
|
| |
|
| |
|
| | if (reasoning) {
|
| | const reasoningToggle = document.createElement("button");
|
| | reasoningToggle.className = "reasoning-toggle";
|
| | reasoningToggle.innerHTML = `
|
| | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| | <polyline points="6 9 12 15 18 9"></polyline>
|
| | </svg>
|
| | <span>Show reasoning</span>
|
| | `;
|
| |
|
| | const reasoningSection = document.createElement("div");
|
| | reasoningSection.className = "reasoning-content";
|
| | reasoningSection.innerHTML = `
|
| | <div class="reasoning-header">
|
| | <div class="reasoning-label">
|
| | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| | <circle cx="12" cy="12" r="10"></circle>
|
| | <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
|
| | <line x1="12" y1="17" x2="12.01" y2="17"></line>
|
| | </svg>
|
| | Reasoning Process
|
| | </div>
|
| | <button class="copy-reasoning-btn" title="Copy reasoning">
|
| | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| | <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
| | <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
| | </svg>
|
| | Copy
|
| | </button>
|
| | </div>
|
| | <div class="reasoning-text">${escapeHtml(reasoning)}</div>
|
| | `;
|
| |
|
| |
|
| | 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);
|
| | }
|
| |
|
| |
|
| | 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 = `
|
| | <span class="message-time">${timeStr}</span>
|
| | <div class="message-actions">
|
| | <button class="msg-action-btn" data-action="regenerate" title="Regenerate">
|
| | <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| | <polyline points="23 4 23 10 17 10"></polyline>
|
| | <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
| | </svg>
|
| | </button>
|
| | <button class="msg-action-btn" data-action="copy" title="Copy">
|
| | <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| | <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
| | <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
| | </svg>
|
| | </button>
|
| | <button class="msg-action-btn msg-action-delete" data-action="delete" title="Delete">
|
| | <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| | <polyline points="3 6 5 6 21 6"></polyline>
|
| | <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
| | </svg>
|
| | </button>
|
| | </div>
|
| | `;
|
| |
|
| |
|
| | 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);
|
| | }
|
| |
|
| |
|
| | 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;
|
| | }
|
| |
|
| |
|
| | addMessage("user", message);
|
| | state.messages.push({ role: "user", content: message });
|
| | updateMessageCounter();
|
| |
|
| |
|
| | try {
|
| | await saveMessageToDB("user", message);
|
| | } catch (error) {
|
| |
|
| | console.warn("User message not saved to DB, but continuing...");
|
| | }
|
| |
|
| |
|
| | elements.messageInput.value = "";
|
| | elements.messageInput.style.height = "auto";
|
| | handleInputChange();
|
| |
|
| |
|
| | const { messageDiv, textDiv, contentDiv } = createStreamingMessage();
|
| | state.isStreaming = true;
|
| | updateSendButton();
|
| |
|
| | let fullContent = "";
|
| | let reasoning = null;
|
| |
|
| | try {
|
| | await callOpenRouterStreaming(state.messages, chunk => {
|
| |
|
| | if (chunk.content) {
|
| | fullContent += chunk.content;
|
| | updateStreamingMessage(textDiv, fullContent);
|
| | }
|
| |
|
| | if (chunk.reasoning) {
|
| | reasoning = chunk.reasoning;
|
| | }
|
| |
|
| | if (chunk.usage) {
|
| | state.lastUsage = chunk.usage;
|
| | updateTokenCounter(false);
|
| | }
|
| | });
|
| |
|
| |
|
| | if (fullContent) {
|
| | finalizeStreamingMessage(messageDiv, textDiv, contentDiv, fullContent, reasoning, new Date().toISOString());
|
| | state.messages.push({ role: "assistant", content: fullContent });
|
| |
|
| |
|
| | if (state.lastUsage) {
|
| | updateTokenCounter(true);
|
| | }
|
| |
|
| |
|
| | try {
|
| | await saveMessageToDB("assistant", fullContent, reasoning);
|
| | } catch (error) {
|
| | console.warn("Assistant message not saved to DB");
|
| | }
|
| |
|
| | trimContextWindow();
|
| | }
|
| | } catch (error) {
|
| | if (error.name === "AbortError") {
|
| |
|
| | 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();
|
| | }
|
| | }
|
| |
|
| |
|
| | function updateSendButton() {
|
| | const hasText = elements.messageInput.value.trim().length > 0;
|
| |
|
| | if (state.isStreaming) {
|
| | elements.sendBtn.innerHTML = `
|
| | <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
| | <rect x="6" y="6" width="12" height="12" rx="2"></rect>
|
| | </svg>
|
| | `;
|
| | elements.sendBtn.disabled = false;
|
| | elements.sendBtn.classList.add("stop-btn");
|
| | elements.sendBtn.onclick = stopStreaming;
|
| | } else {
|
| | elements.sendBtn.innerHTML = `
|
| | <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| | <line x1="22" y1="2" x2="11" y2="13"></line>
|
| | <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
| | </svg>
|
| | `;
|
| | elements.sendBtn.disabled = !hasText;
|
| | elements.sendBtn.classList.remove("stop-btn");
|
| | elements.sendBtn.onclick = sendMessage;
|
| | }
|
| | }
|
| |
|
| |
|
| | 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;
|
| |
|
| |
|
| | 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)}`;
|
| |
|
| |
|
| | 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`;
|
| | }
|
| |
|
| |
|
| | elements.messageCounter.innerHTML = `
|
| | <span style="display: flex; align-items: center; gap: 6px;">
|
| | <span>📊 ${input}→${output}</span>
|
| | <span class="cost-badge">${costStr}</span>
|
| | </span>
|
| | `;
|
| | } 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";
|
| |
|
| |
|
| | 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
|
| | };
|
| |
|
| |
|
| | 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) {
|
| |
|
| | 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)));
|
| | return callOpenRouterStreaming(messages, onChunk, retryCount + 1);
|
| | }
|
| |
|
| |
|
| | 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;
|
| |
|
| |
|
| | 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) {
|
| |
|
| | }
|
| | 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 });
|
| | }
|
| |
|
| |
|
| |
|
| | 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) {
|
| |
|
| | reasoning = (reasoning || "") + delta.reasoning;
|
| | }
|
| |
|
| |
|
| | if (parsed.usage) {
|
| | onChunk({ usage: parsed.usage });
|
| | }
|
| | } catch (e) {
|
| |
|
| | }
|
| | }
|
| | }
|
| | }
|
| |
|
| | if (reasoning) onChunk({ reasoning });
|
| | }
|
| |
|
| |
|
| | function showNotification(message, type = "info") {
|
| |
|
| | 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);
|
| | }
|
| |
|
| |
|
| | 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);
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | 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 = "";
|
| | }
|
| |
|
| |
|
| | if (btnAbout)
|
| | btnAbout.addEventListener("click", e => {
|
| | e.preventDefault();
|
| | openAboutModal();
|
| | });
|
| | if (closeAboutModal) closeAboutModal.addEventListener("click", closeAboutModalFn);
|
| |
|
| |
|
| | aboutModal.addEventListener("click", e => {
|
| | if (e.target === aboutModal) closeAboutModalFn();
|
| | });
|
| |
|
| |
|
| | document.addEventListener("keydown", e => {
|
| | if (e.key === "Escape" && !aboutModal.classList.contains("hidden")) {
|
| | closeAboutModalFn();
|
| | }
|
| | });
|
| | }
|
| |
|
| |
|
| | if (document.readyState === "loading") {
|
| | document.addEventListener("DOMContentLoaded", () => {
|
| | init();
|
| | setupAboutModal();
|
| | });
|
| | } else {
|
| | init();
|
| | setupAboutModal();
|
| | }
|
| |
|