|
|
|
|
|
|
| class ConversationsUI {
|
| constructor() {
|
| this.container = document.getElementById("conversationsList");
|
| this.currentConversationId = null;
|
| this.renameConversationId = null;
|
| this.allConversationsCache = [];
|
| }
|
|
|
|
|
| init() {
|
| this.initRenameModal();
|
| this.initAllConversationsModal();
|
| }
|
|
|
|
|
| async renderConversationsList() {
|
| if (!this.container) return;
|
|
|
| const conversations = await conversationManager.getAllConversations();
|
|
|
| if (conversations.length === 0) {
|
| this.showEmptyState();
|
| return;
|
| }
|
|
|
|
|
| this.container.innerHTML = "";
|
|
|
|
|
| const recentConversations = conversations.slice(0, 10);
|
|
|
| for (const conv of recentConversations) {
|
| const item = await this.createConversationItem(conv);
|
| this.container.appendChild(item);
|
| }
|
|
|
|
|
| if (conversations.length > 10) {
|
| const showAllLink = document.createElement("button");
|
| showAllLink.className = "btn-link w-full";
|
| showAllLink.textContent = `📚 Show All (${conversations.length - 10} more)...`;
|
| showAllLink.style.marginTop = "8px";
|
| showAllLink.addEventListener("click", () => {
|
| this.openAllConversationsModal();
|
| });
|
| this.container.appendChild(showAllLink);
|
| }
|
| }
|
|
|
|
|
| async createConversationItem(conversation) {
|
| const item = document.createElement("div");
|
| item.className = "conversation-item";
|
| item.dataset.conversationId = conversation.id;
|
|
|
|
|
| if (conversation.id === conversationManager.currentConversationId) {
|
| item.classList.add("active");
|
| }
|
|
|
|
|
| const messages = await conversationManager.getMessages(conversation.id);
|
| const messageCount = messages.length;
|
|
|
|
|
| const timeAgo = this.getTimeAgo(conversation.updatedAt);
|
|
|
|
|
| const modelShort = this.getModelShortName(conversation.model);
|
|
|
|
|
| item.innerHTML = `
|
| <div class="conversation-title">${escapeHtml(conversation.title)}</div>
|
| <div class="conversation-meta">
|
| <span>🕐 ${timeAgo}</span>
|
| <span>💬 ${messageCount} msgs</span>
|
| <span>${modelShort}</span>
|
| </div>
|
| <div class="conversation-actions">
|
| <button class="icon-btn export-btn" title="Export" data-action="export">
|
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
| <polyline points="7 10 12 15 17 10"></polyline>
|
| <line x1="12" y1="15" x2="12" y2="3"></line>
|
| </svg>
|
| </button>
|
| <button class="icon-btn edit-btn" title="Rename" data-action="rename">
|
| <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>
|
| <button class="icon-btn delete-btn" title="Delete" data-action="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>
|
| `;
|
|
|
|
|
| item.addEventListener("click", async e => {
|
|
|
| if (e.target.closest(".conversation-actions")) {
|
| return;
|
| }
|
| await this.handleConversationClick(conversation.id);
|
| });
|
|
|
|
|
| const deleteBtn = item.querySelector('[data-action="delete"]');
|
| const renameBtn = item.querySelector('[data-action="rename"]');
|
| const exportBtn = item.querySelector('[data-action="export"]');
|
|
|
| deleteBtn.addEventListener("click", async e => {
|
| e.stopPropagation();
|
| await this.handleDeleteConversation(conversation.id, item);
|
| });
|
|
|
| renameBtn.addEventListener("click", async e => {
|
| e.stopPropagation();
|
| await this.handleRenameConversation(conversation.id);
|
| });
|
|
|
| exportBtn.addEventListener("click", async e => {
|
| e.stopPropagation();
|
| await this.handleExportConversation(conversation.id);
|
| });
|
|
|
| return item;
|
| }
|
|
|
|
|
| async handleConversationClick(conversationId) {
|
| try {
|
| await conversationManager.loadConversation(conversationId);
|
|
|
| } catch (error) {
|
| console.error("Failed to load conversation:", error);
|
| showNotification("Failed to load conversation", "error");
|
| }
|
| }
|
|
|
|
|
| async handleDeleteConversation(conversationId, itemElement) {
|
| if (!confirm("Are you sure you want to delete this conversation? This action cannot be undone.")) return;
|
|
|
| try {
|
| itemElement.classList.add("deleting");
|
| await new Promise(resolve => setTimeout(resolve, 300));
|
|
|
| await conversationManager.deleteConversation(conversationId);
|
|
|
|
|
| if (conversationId === conversationManager.currentConversationId) {
|
| await startNewConversation();
|
| } else {
|
|
|
| await this.refresh();
|
| }
|
|
|
| showNotification("Conversation deleted", "success");
|
| } catch (error) {
|
| console.error("Failed to delete conversation:", error);
|
| showNotification("Failed to delete conversation", "error");
|
| itemElement.classList.remove("deleting");
|
| }
|
| }
|
|
|
|
|
| async handleRenameConversation(conversationId) {
|
| const conversation = await db.conversations.get(conversationId);
|
| if (!conversation) return;
|
|
|
|
|
| this.showRenameModal(conversationId, conversation.title);
|
| }
|
|
|
|
|
| showRenameModal(conversationId, currentTitle) {
|
| this.renameConversationId = conversationId;
|
| const modal = document.getElementById("renameModal");
|
| const input = document.getElementById("renameInput");
|
|
|
| input.value = currentTitle;
|
| modal.classList.remove("hidden");
|
|
|
| setTimeout(() => {
|
| input.focus();
|
| input.select();
|
| }, 100);
|
| }
|
|
|
|
|
| closeRenameModal() {
|
| const modal = document.getElementById("renameModal");
|
| modal.classList.add("hidden");
|
| this.renameConversationId = null;
|
| }
|
|
|
|
|
| async confirmRename() {
|
| const input = document.getElementById("renameInput");
|
| const newTitle = input.value.trim();
|
|
|
| if (!newTitle || !this.renameConversationId) {
|
| this.closeRenameModal();
|
| return;
|
| }
|
|
|
| try {
|
| await conversationManager.updateConversationTitle(this.renameConversationId, newTitle);
|
| await this.refresh();
|
| showNotification("Conversation renamed", "success");
|
| } catch (error) {
|
| console.error("Failed to rename conversation:", error);
|
| showNotification("Failed to rename conversation", "error");
|
| }
|
|
|
| this.closeRenameModal();
|
| }
|
|
|
|
|
| initRenameModal() {
|
| document.getElementById("closeRenameModal").addEventListener("click", () => this.closeRenameModal());
|
| document.getElementById("cancelRename").addEventListener("click", () => this.closeRenameModal());
|
| document.getElementById("confirmRename").addEventListener("click", () => this.confirmRename());
|
|
|
| document.getElementById("renameModal").addEventListener("click", e => {
|
| if (e.target.id === "renameModal") this.closeRenameModal();
|
| });
|
|
|
| document.getElementById("renameInput").addEventListener("keydown", e => {
|
| if (e.key === "Enter") {
|
| e.preventDefault();
|
| this.confirmRename();
|
| }
|
| if (e.key === "Escape") {
|
| this.closeRenameModal();
|
| }
|
| });
|
| }
|
|
|
|
|
|
|
|
|
|
|
| initAllConversationsModal() {
|
| const modal = document.getElementById("allConversationsModal");
|
| const closeBtn = document.getElementById("closeAllConversationsModal");
|
| const searchInput = document.getElementById("conversationSearchInput");
|
|
|
| closeBtn.addEventListener("click", () => this.closeAllConversationsModal());
|
| modal.addEventListener("click", e => {
|
| if (e.target === modal) this.closeAllConversationsModal();
|
| });
|
|
|
|
|
| let searchTimeout;
|
| searchInput.addEventListener("input", e => {
|
| clearTimeout(searchTimeout);
|
| searchTimeout = setTimeout(() => {
|
| this.filterAllConversations(e.target.value);
|
| }, 200);
|
| });
|
|
|
|
|
| document.addEventListener("keydown", e => {
|
| if (e.key === "Escape" && !modal.classList.contains("hidden")) {
|
| this.closeAllConversationsModal();
|
| }
|
| });
|
| }
|
|
|
| async openAllConversationsModal() {
|
| const modal = document.getElementById("allConversationsModal");
|
| const listContainer = document.getElementById("allConversationsList");
|
| const searchInput = document.getElementById("conversationSearchInput");
|
|
|
|
|
| searchInput.value = "";
|
|
|
|
|
| this.allConversationsCache = await conversationManager.getAllConversations();
|
|
|
|
|
| await this.renderAllConversationsList(this.allConversationsCache);
|
|
|
|
|
| modal.classList.remove("hidden");
|
| searchInput.focus();
|
| }
|
|
|
| closeAllConversationsModal() {
|
| const modal = document.getElementById("allConversationsModal");
|
| modal.classList.add("hidden");
|
| }
|
|
|
| async renderAllConversationsList(conversations) {
|
| const listContainer = document.getElementById("allConversationsList");
|
|
|
| if (conversations.length === 0) {
|
| listContainer.innerHTML = `
|
| <div class="all-conversations-empty">
|
| <p>No conversations found</p>
|
| </div>
|
| `;
|
| return;
|
| }
|
|
|
|
|
| let html = `<div class="all-conversations-count">${conversations.length} conversation${conversations.length > 1 ? "s" : ""}</div>`;
|
|
|
| for (const conv of conversations) {
|
| const messages = await conversationManager.getMessages(conv.id);
|
| const msgCount = messages.length;
|
| const timeAgo = this.getTimeAgo(conv.updatedAt);
|
| const modelShort = this.getModelShortName(conv.model);
|
| const isActive = conv.id === conversationManager.currentConversationId;
|
|
|
| html += `
|
| <div class="all-conversations-item ${isActive ? "active" : ""}" data-id="${conv.id}">
|
| <div class="all-conversations-item-info">
|
| <div class="all-conversations-item-title">${escapeHtml(conv.title)}</div>
|
| <div class="all-conversations-item-meta">
|
| <span>🕐 ${timeAgo}</span>
|
| <span>💬 ${msgCount} msgs</span>
|
| <span>${modelShort}</span>
|
| </div>
|
| </div>
|
| <div class="all-conversations-item-actions">
|
| <button class="icon-btn" title="Export" data-action="export" data-id="${conv.id}">
|
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
| <polyline points="7 10 12 15 17 10"></polyline>
|
| <line x1="12" y1="15" x2="12" y2="3"></line>
|
| </svg>
|
| </button>
|
| <button class="icon-btn" title="Delete" data-action="delete" data-id="${conv.id}">
|
| <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>
|
| </div>
|
| `;
|
| }
|
|
|
| listContainer.innerHTML = html;
|
|
|
|
|
| listContainer.querySelectorAll(".all-conversations-item").forEach(item => {
|
| item.addEventListener("click", async e => {
|
|
|
| const actionBtn = e.target.closest("[data-action]");
|
| if (actionBtn) {
|
| e.stopPropagation();
|
| const action = actionBtn.dataset.action;
|
| const id = parseInt(actionBtn.dataset.id);
|
|
|
| if (action === "export") {
|
| await this.handleExportConversation(id);
|
| } else if (action === "delete") {
|
| await this.handleDeleteFromModal(id);
|
| }
|
| return;
|
| }
|
|
|
|
|
| const id = parseInt(item.dataset.id);
|
| await conversationManager.loadConversation(id);
|
| this.closeAllConversationsModal();
|
| await this.refresh();
|
| });
|
| });
|
| }
|
|
|
| async handleDeleteFromModal(conversationId) {
|
| if (!confirm("Delete this conversation?")) return;
|
|
|
| try {
|
| await conversationManager.deleteConversation(conversationId);
|
|
|
|
|
| this.allConversationsCache = this.allConversationsCache.filter(c => c.id !== conversationId);
|
|
|
|
|
| await this.renderAllConversationsList(this.allConversationsCache);
|
| await this.refresh();
|
|
|
| showNotification("Conversation deleted", "success");
|
| } catch (error) {
|
| showNotification("Failed to delete conversation", "error");
|
| }
|
| }
|
|
|
| filterAllConversations(query) {
|
| const q = query.toLowerCase().trim();
|
|
|
| if (!q) {
|
| this.renderAllConversationsList(this.allConversationsCache);
|
| return;
|
| }
|
|
|
| const filtered = this.allConversationsCache.filter(conv => conv.title.toLowerCase().includes(q));
|
|
|
| this.renderAllConversationsList(filtered);
|
| }
|
|
|
|
|
| async handleExportConversation(conversationId) {
|
| try {
|
| const data = await conversationManager.exportConversation(conversationId);
|
| const format = await showExportFormatModal(data.conversation.title);
|
|
|
| if (!format) return;
|
|
|
| const filename = sanitizeFilename(data.conversation.title);
|
|
|
| if (format === "json") {
|
|
|
| const jsonContent = JSON.stringify(data, null, 2);
|
| downloadFile(jsonContent, `${filename}.json`, "application/json");
|
| showNotification("Exported as JSON", "success");
|
| } else {
|
|
|
| let markdown = `# ${data.conversation.title}\n\n`;
|
| markdown += `*Exported: ${new Date().toLocaleString()}*\n`;
|
| markdown += `*Model: ${data.conversation.model}*\n\n---\n\n`;
|
|
|
| for (const msg of data.messages) {
|
| const role = msg.role === "user" ? "**You**" : "**Assistant**";
|
| const time = new Date(msg.timestamp).toLocaleString();
|
| markdown += `### ${role} *(${time})*\n\n${msg.content}\n\n`;
|
| if (msg.reasoning) {
|
| markdown += `<details>\n<summary>Reasoning</summary>\n\n${msg.reasoning}\n\n</details>\n\n`;
|
| }
|
| markdown += "---\n\n";
|
| }
|
|
|
| downloadFile(markdown, `${filename}.md`, "text/markdown");
|
| showNotification("Exported as Markdown", "success");
|
| }
|
| } catch (error) {
|
| console.error("Export failed:", error);
|
| showNotification("Failed to export conversation", "error");
|
| }
|
| }
|
|
|
|
|
| showEmptyState() {
|
| this.container.innerHTML = `
|
| <div class="conversations-empty">
|
| <p>No conversations yet</p>
|
| <p class="text-hint">Start a new chat to begin</p>
|
| </div>
|
| `;
|
| }
|
|
|
|
|
| getTimeAgo(dateString) {
|
| const date = new Date(dateString);
|
| const now = new Date();
|
| const seconds = Math.floor((now - date) / 1000);
|
|
|
| if (seconds < 60) return "Just now";
|
| if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
| if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
| if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
|
|
|
| return date.toLocaleDateString();
|
| }
|
|
|
|
|
| getModelShortName(modelFullName) {
|
| if (modelFullName.includes("120b")) return "GPT-120b";
|
| if (modelFullName.includes("20b")) return "GPT-20b";
|
| return "GPT";
|
| }
|
|
|
|
|
| async refresh() {
|
| await this.renderConversationsList();
|
| }
|
| }
|
|
|
|
|
| const conversationsUI = new ConversationsUI();
|
|
|
|
|
| if (typeof window !== "undefined") {
|
| window.conversationsUI = conversationsUI;
|
| }
|
|
|