Skip to content

Instantly share code, notes, and snippets.

@lzilioli
Last active March 23, 2025 18:33
Show Gist options
  • Save lzilioli/c25687e6aced6d5c530c003467b9dc9d to your computer and use it in GitHub Desktop.
Save lzilioli/c25687e6aced6d5c530c003467b9dc9d to your computer and use it in GitHub Desktop.
typingmind categorize chat plugin

TypingMind Extension - Chat Categorization Plugin

This plugin enhances your chat application by allowing you to categorize chats into folders directly from the chat interface. It adds a "Categorize Chat" button to your sidebar, making chat organization seamless and efficient.

Changelog

2025-03-23

  • cleaned up visual design of button to match other buttons in typingmind sidebar

Categorize Your Chats

How It Works

  • Categorize Chat Button: A new button labeled "Categorize Chat" is added to your application's sidebar.
  • Select a Folder: Clicking the button opens a modal with a list of your existing folders.
  • Assign or Unassign: Choose a folder to assign the current chat to it, or select "-- Unassign Folder --" to remove it from any folder.
  • Confirmation: After selection, a confirmation message appears indicating the chat has been updated.

Usage

  1. Open a Chat: Navigate to the chat you wish to categorize.
  2. Click "Categorize Chat": Find the button in the sidebar and click it.
  3. Choose a Folder: Select a folder from the modal dialog that appears.
  4. Confirm: Click OK to assign the chat to the selected folder.

Future Plans

We plan to enhance the categorization feature by integrating GPT-powered suggestions. In the future, when you click on "Categorize Chat," the plugin will analyze the chat content and suggest appropriate folders using GPT. This AI-driven approach aims to streamline your workflow by automatically recommending the most relevant folder for each chat.


Helper Functions

The plugin exposes several helper functions for advanced usage, accessible via window.tmHelpers:

  • listFolders()

    Retrieves all folders from localStorage.

    window.tmHelpers.listFolders().then((folders) => {
      console.log(folders);
    });
  • listChats()

    Fetches all chats from the IndexedDB keyval object store.

    window.tmHelpers.listChats().then((chats) => {
      console.log(chats);
    });
  • addChatToFolder(folder | folderID, note | noteID)

    Adds a note to a specific folder.

    window.tmHelpers.addNoteToFolder('folder-id', 'This is a new note').then((folder) => {
      console.log('Note added:', folder);
    });
  • chatToMarkdown(chat)

    Converts a chat object into Markdown format.

    window.tmHelpers.listChats().then((chats) => {
      const markdown = window.tmHelpers.chatToMarkdown(chats[0]);
      console.log(markdown);
    });

By incorporating this plugin, you can efficiently organize your chats, making navigation and management more intuitive. The upcoming GPT integration will further enhance this experience by providing intelligent folder suggestions based on chat content.

(() => {
// Function to open IndexedDB with the 'keyval-store' database
function openDB() {
return new Promise((resolve, reject) => {
const dbName = 'keyval-store'; // Use 'keyval-store' as the database name
const request = indexedDB.open(dbName);
request.onsuccess = () => {
const db = request.result;
resolve(db);
};
request.onerror = () => {
reject(request.error);
};
});
}
// Function to get all chats from the 'keyval' object store
function listChats() {
return new Promise(async (resolve, reject) => {
try {
const db = await openDB();
const objectStoreName = 'keyval'; // Use 'keyval' as the object store name
const transaction = db.transaction([objectStoreName], 'readonly');
const store = transaction.objectStore(objectStoreName);
const chats = [];
// Open a cursor to iterate over all entries
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const key = cursor.key;
const value = cursor.value;
// Check if the key corresponds to a chat
if (key.startsWith('CHAT_')) {
chats.push(value);
}
cursor.continue();
} else {
resolve(chats);
}
};
request.onerror = () => {
reject(request.error);
};
} catch (error) {
reject(error);
}
});
}
// Function to list all folders from localStorage
function listFolders() {
return new Promise((resolve) => {
// Load folders from localStorage
const folderListJSON = localStorage.getItem('TM_useFolderList');
const folders = folderListJSON ? JSON.parse(folderListJSON) : [];
resolve(folders);
});
}
// Function to get a single chat by ID from the 'keyval' object store
function getChatByID(db, chatID) {
return new Promise((resolve, reject) => {
const objectStoreName = 'keyval'; // Use 'keyval' as the object store name
const transaction = db.transaction([objectStoreName], 'readonly');
const store = transaction.objectStore(objectStoreName);
const key = `CHAT_${chatID}`;
const request = store.get(key);
request.onsuccess = () => {
const chat = request.result;
if (chat) {
resolve(chat);
} else {
reject(new Error('Chat not found.'));
}
};
request.onerror = () => {
reject(request.error);
};
});
}
// Function to update a chat in the 'keyval' object store
function updateChat(db, chat) {
return new Promise((resolve, reject) => {
const objectStoreName = 'keyval'; // Use 'keyval' as the object store name
const transaction = db.transaction([objectStoreName], 'readwrite');
const store = transaction.objectStore(objectStoreName);
const key = `CHAT_${chat.id}`;
const request = store.put(chat, key);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error);
};
});
}
// Function to prompt the user to select a folder
function promptUserToSelectFolder(folders) {
return new Promise((resolve) => {
// Create the overlay
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
overlay.style.zIndex = '1000';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
// Create the modal
const modal = document.createElement('div');
modal.style.backgroundColor = '#fff';
modal.style.color = '#000'; // Ensure text color is readable
modal.style.padding = '20px';
modal.style.borderRadius = '8px';
modal.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.3)';
modal.style.maxWidth = '90%';
modal.style.maxHeight = '80%';
modal.style.overflowY = 'auto';
// Create the title
const title = document.createElement('h2');
title.textContent = 'Select a Folder';
title.style.marginTop = '0';
modal.appendChild(title);
// Create the select element
const select = document.createElement('select');
select.style.width = '100%';
select.style.marginBottom = '20px';
select.style.padding = '8px';
select.style.borderRadius = '4px';
select.style.border = '1px solid #ccc';
// Add an option to unassign the folder
const unassignOption = document.createElement('option');
unassignOption.value = '';
unassignOption.textContent = '-- Unassign Folder --';
select.appendChild(unassignOption);
// Populate the select options
folders.forEach((folder) => {
const option = document.createElement('option');
option.value = folder.id;
option.textContent = folder.title;
select.appendChild(option);
});
modal.appendChild(select);
// Create the Cancel and OK buttons
const buttonContainer = document.createElement('div');
buttonContainer.style.display = 'flex';
buttonContainer.style.justifyContent = 'flex-end';
buttonContainer.style.gap = '10px';
const cancelButton = document.createElement('button');
cancelButton.textContent = 'Cancel';
cancelButton.style.padding = '8px 16px';
cancelButton.style.border = 'none';
cancelButton.style.borderRadius = '4px';
cancelButton.style.backgroundColor = '#f1f1f1';
cancelButton.style.cursor = 'pointer';
cancelButton.onclick = () => {
document.body.removeChild(overlay);
resolve(null);
};
const okButton = document.createElement('button');
okButton.textContent = 'OK';
okButton.style.padding = '8px 16px';
okButton.style.border = 'none';
okButton.style.borderRadius = '4px';
okButton.style.backgroundColor = '#4F46E5';
okButton.style.color = 'white';
okButton.style.cursor = 'pointer';
okButton.onclick = () => {
const selectedFolderID = select.value;
document.body.removeChild(overlay);
resolve(selectedFolderID);
};
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(okButton);
modal.appendChild(buttonContainer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
});
}
// Function to add a chat to a folder
async function addChatToFolder(chatOrChatID, folderOrFolderID) {
try {
const db = await openDB();
// Determine chat ID
let chatID;
if (typeof chatOrChatID === 'string') {
chatID = chatOrChatID;
} else if (chatOrChatID && chatOrChatID.id) {
chatID = chatOrChatID.id;
} else {
throw new Error('Invalid chat or chat ID.');
}
// Determine folder ID
let folderID;
if (typeof folderOrFolderID === 'string') {
folderID = folderOrFolderID;
} else if (folderOrFolderID && folderOrFolderID.id) {
folderID = folderOrFolderID.id;
} else {
throw new Error('Invalid folder or folder ID.');
}
// Get the chat
const chat = await getChatByID(db, chatID);
// Update the folderID of the chat
chat.folderID = folderID;
await updateChat(db, chat);
// Optionally, you can trigger an event or refresh the UI to reflect changes
console.log(`Chat '${chat.chatTitle || chat.title || 'Untitled Chat'}' has been moved to folder ID: '${folderID}'.`);
} catch (error) {
console.error('Error adding chat to folder:', error);
}
}
// Expose the helper functions on window.tmHelpers
window.tmHelpers = {
...(window.tmHelpers || {}),
listFolders,
listChats,
addChatToFolder, // Expose the new helper function
};
// Function to categorize the current chat
async function categorizeCurrentChat() {
try {
const chatIDFromURL = window.location.hash.match(/#chat=([^&]+)/);
if (!chatIDFromURL || !chatIDFromURL[1]) {
alert('No chat selected.');
return;
}
const chatID = chatIDFromURL[1];
const db = await openDB();
// Load folders from localStorage
const folders = await listFolders();
if (folders.length === 0) {
alert('No folders found. Please create a folder first.');
return;
}
// Prompt the user with a list of folders
const selectedFolderID = await promptUserToSelectFolder(folders);
if (selectedFolderID === null) {
// User canceled the prompt
return;
}
const chat = await getChatByID(db, chatID);
// Update the folderID of the chat
if (selectedFolderID === '') {
// Unassign folder
delete chat.folderID;
} else {
chat.folderID = selectedFolderID;
}
await updateChat(db, chat);
// Confirmation message
const folderName = selectedFolderID
? folders.find((f) => f.id === selectedFolderID)?.title || 'Unknown'
: 'None';
alert(`Chat has been categorized under folder: '${folderName}'.`);
// Optionally, trigger an event or refresh the UI to reflect changes
} catch (error) {
console.error('Error categorizing current chat:', error);
alert('An error occurred while categorizing the current chat.');
}
}
// Function to add the Categorize button to the sidebar
function addCategorizeButton() {
// Check if button already exists to avoid duplicates
if (document.querySelector('[data-element-id="workspace-tab-categorize"]')) {
return true;
}
// Find the settings button for reference positioning
const settingsButton = document.querySelector('button[data-element-id="workspace-tab-settings"]');
if (!settingsButton || !settingsButton.parentElement) {
return false;
}
// Create custom style for the text to ensure word breaking and matching font
const styleElement = document.createElement('style');
styleElement.textContent = `
.categorize-btn-text {
font-family: inherit;
font-size: 11px;
line-height: 1.2;
width: 100%;
text-align: center;
overflow: visible;
word-break: break-word;
hyphens: auto;
padding: 0;
white-space: normal;
display: inline-block;
}
`;
document.head.appendChild(styleElement);
// Create Categorize button
const categorizeButton = document.createElement('button');
categorizeButton.setAttribute('data-element-id', 'workspace-tab-categorize');
categorizeButton.className = 'cursor-default group flex items-center justify-center p-1 text-sm font-medium flex-col group focus:outline-0 focus:text-white text-white/70 text-white/70 hover:bg-white/20 self-stretch h-12 md:h-[50px] px-0.5 py-1.5 rounded-xl flex-col justify-start items-center gap-1.5 flex transition-colors';
// Create the icon span
const categorizeIconSpan = document.createElement('span');
categorizeIconSpan.className = 'block group-hover:bg-white/30 w-[35px] h-[35px] transition-all rounded-lg flex items-center justify-center group-hover:text-white/90';
// Folder icon SVG
categorizeIconSpan.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M3 7V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V9C21 7.89543 20.1046 7 19 7H11L9 5H5C3.89543 5 3 5.89543 3 7Z" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
// Create text span with custom class
const categorizeTextSpan = document.createElement('span');
categorizeTextSpan.className = 'font-normal self-stretch text-center text-xs leading-4 md:leading-none categorize-btn-text';
categorizeTextSpan.innerHTML = 'Add to<br>Folder';
// Assemble button
categorizeButton.appendChild(categorizeIconSpan);
categorizeButton.appendChild(categorizeTextSpan);
categorizeButton.onclick = categorizeCurrentChat;
// Insert button before settings button
settingsButton.parentElement.insertBefore(categorizeButton, settingsButton);
return true;
}
// Use MutationObserver to watch for when the button can be added
const observer = new MutationObserver(() => {
if (addCategorizeButton()) {
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// Try to add the button immediately and periodically
if (!addCategorizeButton()) {
const maxAttempts = 10;
let attempts = 0;
const interval = setInterval(() => {
if (addCategorizeButton() || attempts >= maxAttempts) {
clearInterval(interval);
}
attempts++;
}, 1000);
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment