// ==UserScript==
// @name Google AI Studio profile manager
// @version 1.11
// @description Manage and switch between multiple custom system instruction profiles in Google AI Studio.
// @author LetMeFixIt
// @license MIT
// @match https://aistudio.google.com/*prompts/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @namespace https://greasyfork.org/users/1520954
// ==/UserScript==
(async function() {
'use strict';
//================================================================================
// SECTION: Configuration & State
//================================================================================
const SCRIPT_NAME = 'AI Studio Profile Manager';
const STORAGE_KEY_PROFILES = 'injector_profiles';
const STORAGE_KEY_GEMINI_API_KEY = 'injector_gemini_api_key';
const STORAGE_KEY_DEFAULT_PROFILE_ID = 'injector_default_profile_id';
const STORAGE_KEY_CUSTOM_ICONS = 'injector_custom_icons';
const STORAGE_KEY_META_PROMPT_WITH_CONTEXT = 'injector_meta_prompt_with_context';
const STORAGE_KEY_META_PROMPT_WITHOUT_CONTEXT = 'injector_meta_prompt_without_context';
const SELECTORS = {
systemInstructionsTextArea: 'textarea[aria-label="System instructions"]',
systemInstructionsCard: '[data-test-system-instructions-card]',
runSettingsButton: 'button[aria-label="Toggle run settings panel"]'
};
const TIMEOUTS = {
DOCK_HIDE_DELAY: 50,
UI_SETTLE: 100,
DOCK_DEBOUNCE: 1000,
MODAL_UI_WAIT: 3000,
WAIT_FOR_ELEMENT: 5000
};
// The base URL for the Gemini API. The model name will be appended.
const GEMINI_API_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models/';
const ICONIFY_API_URL = 'https://api.iconify.design';
const AVAILABLE_MODELS = ['gemini-2.0-flash', 'gemini-2.5-flash', 'gemini-2.5-pro'];
// The default icon catalog is now empty. Users will add icons via the import/search features.
const DEFAULT_META_PROMPT_WITH_CONTEXT = `You are an expert prompt engineer. Your task is to refine the original system instruction based on the provided guidance and conversation context. The refined instruction should be more effective and relevant to the topic. Output ONLY the refined instruction text and nothing else.\n\nRefinement Guidance:\n---\n{GUIDANCE}\n\nConversation Context:\n---\n{CONTEXT}\n\nOriginal Instruction:\n---\n{PROMPT}`;
const DEFAULT_META_PROMPT_WITHOUT_CONTEXT = `You are an expert prompt engineer. Your task is to refine the following system instruction based on the provided guidance to be more detailed, robust, and effective for guiding a large language model. Ensure the output is clear, unambiguous, and provides a strong persona. Output ONLY the refined instruction text and nothing else.\n\nRefinement Guidance:\n---\n{GUIDANCE}\n\nOriginal Instruction:\n---\n{PROMPT}`;
const ICON_CATALOG = {
// This object is intentionally left empty.
};
const state = {
profiles: [],
customIcons: {},
defaultProfileId: null
};
//================================================================================
// SECTION: Main Entry Point
//================================================================================
async function main() {
await loadProfiles();
await loadDefaultProfile();
await loadCustomIcons();
setupMenuCommands();
createProfileDock();
renderProfilesInDock();
attachDockListeners();
await autoInjectDefaultProfile();
}
//================================================================================
// SECTION: Data Management
//================================================================================
async function loadProfiles() {
const profilesJSON = await GM_getValue(STORAGE_KEY_PROFILES, null);
if (profilesJSON) {
try {
state.profiles = JSON.parse(profilesJSON);
} catch (e) {
console.error(`[${SCRIPT_NAME}] Error parsing profiles from storage. Resetting to default.`, e);
createDefaultProfiles();
}
} else {
createDefaultProfiles();
await saveProfiles();
}
}
async function saveProfiles() {
try {
await GM_setValue(STORAGE_KEY_PROFILES, JSON.stringify(state.profiles));
} catch (e) {
console.error(`[${SCRIPT_NAME}] Error saving profiles!`, e);
showToast('Error saving profiles!', 'error');
}
}
async function loadDefaultProfile() {
const defaultId = await GM_getValue(STORAGE_KEY_DEFAULT_PROFILE_ID, null);
// Ensure the ID is a number if it exists
state.defaultProfileId = defaultId ? parseInt(defaultId, 10) : null;
}
async function saveDefaultProfile() {
await GM_setValue(STORAGE_KEY_DEFAULT_PROFILE_ID, state.defaultProfileId);
}
async function loadCustomIcons() {
const iconsJSON = await GM_getValue(STORAGE_KEY_CUSTOM_ICONS, '{}');
try {
state.customIcons = JSON.parse(iconsJSON);
} catch (e) {
console.error(`[${SCRIPT_NAME}] Error parsing custom icons. Resetting.`, e);
state.customIcons = {};
}
}
async function saveCustomIcons() {
try {
await GM_setValue(STORAGE_KEY_CUSTOM_ICONS, JSON.stringify(state.customIcons));
} catch (e) {
console.error(`[${SCRIPT_NAME}] Error saving custom icons!`, e);
}
}
async function addProfile(profileData) {
const newProfile = {
id: Date.now(),
...profileData
};
state.profiles.push(newProfile);
await saveProfiles();
renderProfilesInDock();
}
async function updateProfile(updatedProfile) {
const index = state.profiles.findIndex(p => p.id === updatedProfile.id);
if (index !== -1) {
state.profiles[index] = updatedProfile;
await saveProfiles();
renderProfilesInDock();
}
}
async function deleteProfile(profileId) {
state.profiles = state.profiles.filter(p => p.id !== profileId);
if (state.defaultProfileId === profileId) {
state.defaultProfileId = null;
await saveDefaultProfile();
}
await saveProfiles();
renderProfilesInDock();
}
function createDefaultProfiles() {
state.profiles = [{
id: Date.now(),
name: "Creative Writer",
icon: "feather",
instructions: "You are a creative and eloquent writer, known for your vivid descriptions and ability to craft compelling narratives. Your tone is inspiring and slightly whimsical. You help users brainstorm ideas, overcome writer's block, and refine their prose."
}, {
id: Date.now() + 1,
name: "Code Assistant",
icon: "code",
instructions: "You are a master programmer and an expert in software architecture. You provide clean, efficient, and well-documented code in multiple programming languages. You can explain complex technical concepts clearly and concisely. Your primary goal is to help users write better code and solve technical challenges."
}];
}
function setupMenuCommands() {
GM_registerMenuCommand("Add New Profile", handleAddNewProfileClick);
GM_registerMenuCommand("Reset to Default Profiles", async () => {
const confirmed = await showConfirmationModal({
title: "Reset Profiles",
message: "This will delete all your current profiles and restore the defaults. Are you sure?"
});
if (confirmed) {
createDefaultProfiles();
await saveProfiles();
renderProfilesInDock();
showToast("Profiles have been reset to default.", "success");
}
});
}
async function autoInjectDefaultProfile() {
if (state.defaultProfileId !== null) {
const defaultProfile = state.profiles.find(p => p.id === state.defaultProfileId);
if (defaultProfile) {
await injectText(defaultProfile.instructions);
showToast(`Default profile "${defaultProfile.name}" auto-injected.`, 'success');
// Visually activate the button in the dock
const button = document.querySelector(`.profile-button[data-profile-id="${defaultProfile.id}"]`);
if (button) {
button.classList.add('active');
}
}
}
}
//================================================================================
// SECTION: UI Rendering
//================================================================================
/**
* Programmatically builds an SVG element to bypass TrustedHTML policies.
* @param {string} name - The name of the icon in ICON_CATALOG.
* @returns {SVGElement} The fully constructed SVG DOM element.
*/
function createIconElement(name, instruction = null) {
const fallbackIcon = { viewBox: '0 0 24 24', children: [{ tag: 'path', attrs: { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' } }, { tag: 'polyline', attrs: { points: '14 2 14 8 20 8' } }] }; // file icon
const iconData = instruction || state.customIcons[name] || ICON_CATALOG[name] || fallbackIcon;
if (!iconData) {
console.error(`Icon "${name}" not found or has invalid data. Using fallback.`);
return createIconElement(null, fallbackIcon); // Recurse with fallback
}
const xmlns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(xmlns, 'svg');
svg.setAttribute('viewBox', iconData.viewBox);
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
if (iconData.children) {
iconData.children.forEach(childData => {
const el = document.createElementNS(xmlns, childData.tag);
for (const [attr, value] of Object.entries(childData.attrs)) {
el.setAttribute(attr, value);
}
svg.appendChild(el);
});
}
return svg;
}
function createProfileDock() {
const trigger = document.createElement('div');
trigger.id = 'profile-dock-trigger';
const container = document.createElement('div');
container.id = 'profile-dock-container';
const dock = document.createElement('div'); dock.id = 'profile-dock';
container.appendChild(dock);
document.body.append(trigger, container);
let hideTimeout;
const showDock = () => {
// If the trigger is in a debounced state, do not show the dock.
if (trigger.dataset.debounced === 'true') {
return;
}
clearTimeout(hideTimeout);
container.classList.add('visible');
};
const hideDock = () => {
// Before hiding, double-check that the mouse isn't currently over the trigger or the container.
// This prevents a race condition where a quick mouse-out from the trigger leaves the dock open.
if (trigger.matches(':hover') || container.matches(':hover')) {
return; // The mouse has re-entered one of the areas, so do not hide.
}
container.classList.remove('visible');
};
const startHideTimer = () => {
clearTimeout(hideTimeout);
hideTimeout = setTimeout(hideDock, TIMEOUTS.DOCK_HIDE_DELAY);
};
trigger.addEventListener('mouseenter', showDock);
trigger.addEventListener('mouseleave', startHideTimer);
container.addEventListener('mouseenter', showDock);
container.addEventListener('mouseleave', startHideTimer);
}
function renderProfilesInDock() {
const dock = document.getElementById('profile-dock');
if (!dock) return;
while (dock.firstChild) { dock.removeChild(dock.firstChild); }
if (state.profiles.length === 0) {
const emptyState = document.createElement('div');
emptyState.className = 'dock-empty-state';
emptyState.textContent = "No profiles. Click the '+' button to add one!";
dock.appendChild(emptyState);
} else {
state.profiles.forEach(profile => {
const button = createProfileButton(profile);
if (button) dock.appendChild(button);
});
}
const addButton = document.createElement('button');
addButton.className = 'profile-button add-new-button';
addButton.textContent = '+';
addButton.title = 'Add New Profile';
dock.appendChild(addButton);
}
/**
* Creates a single HTML button element for a given profile.
* @param {object} profile - The profile object.
* @returns {HTMLButtonElement} The created button element.
*/
function createProfileButton(profile) {
const button = document.createElement('button');
button.className = 'profile-button';
button.title = profile.name;
button.dataset.profileId = profile.id;
button.draggable = true;
const svgNode = createIconElement(profile.icon);
if (!svgNode) return null; // Don't create a button if the icon is invalid
button.appendChild(svgNode);
return button;
}
//================================================================================
// SECTION: AI Refiner Logic
//================================================================================
/**
* Scrapes the current chat history from the AI Studio page.
* @async
* @returns {Promise<string>} A formatted string of the conversation history.
*/
async function getChatContext() {
const turns = document.querySelectorAll('ms-chat-turn');
if (!turns.length) return [];
const context = [];
for (const turn of turns) {
const userTurn = turn.querySelector('.user-prompt-container');
const modelTurn = turn.querySelector('.model-prompt-container');
if (userTurn) {
// User turns can have multiple text/media chunks. We'll join them.
const parts = Array.from(userTurn.querySelectorAll('ms-cmark-node'))
.map(node => node.textContent.trim())
.filter(text => text)
.join('\n');
if (parts) context.push({ role: 'user', parts });
} else if (modelTurn) {
// Model turns also can have multiple parts. We specifically target the response nodes.
const parts = Array.from(modelTurn.querySelectorAll('ms-prompt-chunk > ms-text-chunk > ms-cmark-node'))
.map(node => node.textContent.trim())
.filter(text => text)
.join('\n');
if (parts) context.push({ role: 'model', parts });
}
}
return context;
}
/**
* Calls the Gemini API to refine a given prompt.
* @async
* @param {{originalPrompt: string, chatContext?: string}} options
* @param {string} model - The model to use for the API call.
* @returns {Promise<string>} The refined prompt text from the API.
*/
async function fetchRefinedPrompt({ originalPrompt, chatContext = '', guidance = '' }, model) {
const apiKey = await GM_getValue(STORAGE_KEY_GEMINI_API_KEY);
if (!apiKey) {
throw new Error("Gemini API key is not set. Please set it in the AI Configuration section.");
}
const metaPromptTemplateWithContext = await GM_getValue(STORAGE_KEY_META_PROMPT_WITH_CONTEXT, DEFAULT_META_PROMPT_WITH_CONTEXT);
const metaPromptTemplateWithoutContext = await GM_getValue(STORAGE_KEY_META_PROMPT_WITHOUT_CONTEXT, DEFAULT_META_PROMPT_WITHOUT_CONTEXT);
// The getChatContext function returns an array of objects. Stringify it for the {CONTEXT} placeholder.
const contextString = (Array.isArray(chatContext) && chatContext.length > 0) ? JSON.stringify(chatContext, null, 2) : '';
const finalMetaPrompt = (contextString ? metaPromptTemplateWithContext : metaPromptTemplateWithoutContext)
.replace('{CONTEXT}', contextString)
.replace('{PROMPT}', originalPrompt)
.replace('{GUIDANCE}', guidance || 'No specific guidance provided. Refine for general quality and effectiveness.');
const body = {
contents: [{ parts: [{ text: finalMetaPrompt }] }]
};
const response = await fetch(`${GEMINI_API_BASE_URL}${model}:generateContent?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const errorData = await response.json();
console.error("Gemini API Error:", errorData);
throw new Error(`API request failed: ${response.status} ${response.statusText}. Check console for details.`);
}
const data = await response.json();
const refinedText = data?.candidates?.[0]?.content?.parts?.[0]?.text;
if (!refinedText) throw new Error("Invalid API response format from Gemini.");
return refinedText.trim();
}
/**
* Searches the Iconify API for icons matching a query.
* @param {string} query The search term.
* @returns {Promise<Array<string>>} A promise that resolves to an array of icon names (e.g., "mdi:kangaroo").
*/
async function searchIconify(query) {
const response = await fetch(`${ICONIFY_API_URL}/search?query=${encodeURIComponent(query)}&limit=32`);
if (!response.ok) throw new Error("Iconify API search failed.");
const data = await response.json();
return data.icons || [];
}
/**
* Fetches the SVG data for a specific Iconify icon.
* @param {string} iconName The full name of the icon (e.g., "mdi:kangaroo").
* @returns {Promise<string>} A promise that resolves to the raw SVG string.
*/
async function fetchIconifySVG(iconName) {
// The API returns the SVG content directly.
const response = await fetch(`${ICONIFY_API_URL}/${iconName.replace(':', '/')}.svg`);
if (!response.ok) throw new Error("Failed to fetch Iconify SVG data.");
const svgText = await response.text();
// The API sometimes includes width/height, which we don't want.
return svgText.replace(/ width="[^"]*"/g, '').replace(/ height="[^"]*"/g, '');
}
//================================================================================
// SECTION: Event Handling & Modals
//================================================================================
function attachDockListeners() {
const dock = document.getElementById('profile-dock');
if (!dock) return;
// Use event delegation for efficiency
dock.addEventListener('click', async (event) => {
const button = event.target.closest('button');
if (button) {
if (button.classList.contains('add-new-button')) {
await handleAddNewProfileClick();
} else if (button.classList.contains('profile-button') && button.dataset.profileId) {
handleProfileClick(button);
}
} else if (event.target === dock) { // Clicked on the dock background
const container = document.getElementById('profile-dock-container');
// Instantly hide the dock and debounce the trigger to prevent accidental re-opening.
container.style.transition = 'none';
container.classList.remove('visible');
void container.offsetHeight; // Force reflow
container.style.transition = '';
const trigger = document.getElementById('profile-dock-trigger');
if (trigger) {
trigger.dataset.debounced = 'true';
setTimeout(() => trigger.dataset.debounced = 'false', TIMEOUTS.DOCK_DEBOUNCE);
}
}
});
dock.addEventListener('contextmenu', async (event) => {
event.preventDefault();
const button = event.target.closest('.profile-button');
if (button && button.dataset.profileId) {
await handleProfileRightClick(event, button);
}
});
// --- Drag and Drop Logic ---
let draggedElementId = null;
// Fired on the element that is being dragged
dock.addEventListener('dragstart', (e) => {
const target = e.target.closest('.profile-button[data-profile-id]');
if (target) {
draggedElementId = target.dataset.profileId;
// Add a visual cue to the item being dragged
e.dataTransfer.setData('text/plain', null); // Required for Firefox to allow dragging
setTimeout(() => target.classList.add('dragging'), 0);
} else {
e.preventDefault(); // Prevent dragging the '+' button
}
});
// Fired when the drag operation ends (e.g., mouse up, escape key)
dock.addEventListener('dragend', (e) => {
const target = e.target.closest('.profile-button[data-profile-id]');
if (target) {
target.classList.remove('dragging');
}
draggedElementId = null;
});
// Fired when a valid drop target is hovered
dock.addEventListener('dragover', (e) => {
e.preventDefault(); // Necessary to allow dropping
});
// Fired when an element is dropped on a valid drop target
dock.addEventListener('drop', async (e) => {
e.preventDefault();
const dropTarget = e.target.closest('.profile-button[data-profile-id]');
if (!dropTarget || !draggedElementId || dropTarget.dataset.profileId === draggedElementId) {
return;
}
const draggedIndex = state.profiles.findIndex(p => p.id == draggedElementId);
const targetIndex = state.profiles.findIndex(p => p.id == dropTarget.dataset.profileId);
if (draggedIndex === -1 || targetIndex === -1) return;
// Reorder the array
const [draggedItem] = state.profiles.splice(draggedIndex, 1);
state.profiles.splice(targetIndex, 0, draggedItem);
await saveProfiles();
renderProfilesInDock(); // Re-render to reflect the new order
});
}
function handleProfileClick(buttonEl) {
const profileId = parseInt(buttonEl.dataset.profileId, 10);
const profile = state.profiles.find(p => p.id === profileId);
if (profile) {
injectText(profile.instructions);
showToast(`Profile "${profile.name}" injected.`, 'success');
// Update active state
document.querySelectorAll('#profile-dock .profile-button').forEach(btn => btn.classList.remove('active'));
buttonEl.classList.add('active');
}
}
async function handleProfileRightClick(event, buttonEl) {
const profileId = parseInt(buttonEl.dataset.profileId, 10);
const profile = state.profiles.find(p => p.id === profileId);
if (!profile) return;
const choice = await showContextMenu(event, profile);
if (choice === 'edit') {
const updatedData = await showProfileEditorModal({ profile });
if (updatedData) {
await updateProfile({ ...profile, ...updatedData });
showToast(`Profile "${profile.name}" updated.`, 'success');
}
} else if (choice === 'delete') {
const confirmed = await showConfirmationModal({
title: "Delete Profile",
message: `Are you sure you want to delete the "${profile.name}" profile? This cannot be undone.`
});
if (confirmed) {
await deleteProfile(profile.id);
showToast(`Profile "${profile.name}" deleted.`, 'success');
}
} else if (choice === 'default') {
// Toggle the default status
if (state.defaultProfileId === profile.id) {
state.defaultProfileId = null; // Unset if it's already the default
showToast(`"${profile.name}" is no longer the default profile.`, 'info');
} else {
state.defaultProfileId = profile.id; // Set as the new default
showToast(`"${profile.name}" is now the default profile.`, 'success');
}
await saveDefaultProfile();
}
}
async function handleAddNewProfileClick() {
const newProfileData = await showProfileEditorModal({ profile: null });
if (newProfileData) {
await addProfile(newProfileData);
showToast(`Profile "${newProfileData.name}" created!`, 'success');
}
}
/**
* Creates a base modal structure with an overlay.
* @param {string} modalId - The ID for the modal element.
* @param {string} overlayId - The ID for the overlay element.
* @returns {{modal: HTMLDivElement, overlay: HTMLDivElement, closeModal: function(any): void, resolve: function}}
*/
function createBaseModal(modalId = 'injector-modal', overlayId = 'injector-overlay') {
const overlay = document.createElement('div');
overlay.id = overlayId;
const modal = document.createElement('div');
modal.id = modalId;
document.body.append(overlay, modal);
// This function now takes the promise's `resolve` function as an argument.
// It handles DOM removal and then safely resolves the promise.
const closeModalAndResolve = (resolve, data) => {
overlay.remove();
modal.remove();
// Per memory_bank.md, use queueMicrotask to avoid race conditions.
queueMicrotask(() => resolve(data));
};
// The function returns the elements and the new closing utility.
return { modal, overlay, closeModalAndResolve };
}
function showProfileEditorModal({ profile = null }) {
return new Promise(async (resolve) => {
const isEditing = profile !== null;
const title = isEditing ? "Edit Profile" : "Add New Profile";
let selectedIcon = isEditing ? profile.icon : (Object.keys(state.customIcons)[0] || null);
// State for refinement history
let refinementHistory = [isEditing ? profile.instructions : ''];
let historyIndex = 0;
// Create the modal elements. `closeModalAndResolve` is a utility function.
const { modal, overlay, closeModalAndResolve } = createBaseModal();
// The primary `closeModal` function for this promise's scope.
const closeModal = (data) => {
closeModalAndResolve(resolve, data);
};
const mainContent = document.createElement('div');
mainContent.id = 'injector-modal-main-content';
const h2 = document.createElement('h2');
h2.textContent = title;
const iconLabelWrapper = document.createElement('div');
iconLabelWrapper.className = 'label-wrapper';
const nameLabel = document.createElement('label');
nameLabel.className = 'injector-label';
nameLabel.textContent = 'Profile Name';
const nameInput = document.createElement('input');
nameInput.id = 'injector-input-name';
nameInput.type = 'text';
nameInput.placeholder = 'e.g., "Code Assistant"';
nameInput.value = isEditing ? profile.name : '';
const importSvgBtn = document.createElement('button');
importSvgBtn.id = 'import-svg-btn';
importSvgBtn.className = 'inline-button';
importSvgBtn.textContent = 'Import...';
const iconLabel = document.createElement('label');
iconLabel.className = 'injector-label';
iconLabel.textContent = 'Icon';
const iconPicker = document.createElement('div');
iconPicker.className = 'icon-picker-grid';
const renderIcons = () => {
if (!iconLabelWrapper.contains(iconLabel)) iconLabelWrapper.append(iconLabel, importSvgBtn);
// If there are no icons at all, the selected icon should be null
if (Object.keys(state.customIcons).length === 0 && Object.keys(ICON_CATALOG).length === 0) {
selectedIcon = null;
}
while (iconPicker.firstChild) { iconPicker.removeChild(iconPicker.firstChild); } // Clear previous icons
Object.keys(ICON_CATALOG).forEach(iconName => {
const iconButton = document.createElement('button');
iconButton.className = 'icon-picker-button';
if (iconName === selectedIcon) {
iconButton.classList.add('selected');
}
iconButton.appendChild(createIconElement(iconName));
iconButton.dataset.iconName = iconName;
iconPicker.appendChild(iconButton);
});
Object.keys(state.customIcons).forEach(iconName => {
const iconButton = document.createElement('button');
iconButton.className = 'icon-picker-button custom-icon-wrapper';
if (iconName === selectedIcon) {
iconButton.classList.add('selected');
}
iconButton.appendChild(createIconElement(iconName));
iconButton.dataset.iconName = iconName;
const deleteBtn = document.createElement('div');
deleteBtn.className = 'custom-icon-delete-btn';
deleteBtn.textContent = '×';
deleteBtn.title = 'Delete this custom icon';
deleteBtn.dataset.iconToDelete = iconName;
iconButton.appendChild(deleteBtn);
iconPicker.appendChild(iconButton);
});
// Add Search button
const searchBtn = document.createElement('button');
searchBtn.id = 'search-icon-btn';
searchBtn.className = 'icon-picker-button ai-generate'; // Reuse style
searchBtn.title = 'Search for icons online';
searchBtn.textContent = '🔍';
iconPicker.appendChild(searchBtn);
};
iconPicker.addEventListener('click', (e) => {
const button = e.target.closest('.icon-picker-button');
if (button && button.dataset.iconName) { // Ensure it's not the generate button
selectedIcon = button.dataset.iconName;
renderIcons();
}
});
// Listener for deleting custom icons
iconPicker.addEventListener('click', async (e) => {
const deleteBtn = e.target.closest('.custom-icon-delete-btn');
if (!deleteBtn) return;
e.stopPropagation(); // Prevent the icon from being selected
const iconToDelete = deleteBtn.dataset.iconToDelete;
if (iconToDelete && state.customIcons[iconToDelete]) {
delete state.customIcons[iconToDelete];
await saveCustomIcons();
showToast("Custom icon deleted.", "info");
// Fallback to the first available custom icon, or null
if (selectedIcon === iconToDelete) {
selectedIcon = Object.keys(state.customIcons)[0] || Object.keys(ICON_CATALOG)[0] || null;
}
renderIcons(); // Re-render the picker
}
});
const instructionsSection = document.createElement('details');
instructionsSection.className = 'instructions-section';
instructionsSection.open = true; // Open by default
const instructionsSummary = document.createElement('summary');
instructionsSummary.textContent = 'System Instructions';
const instructionsContentWrapper = document.createElement('div');
instructionsContentWrapper.className = 'instructions-content-wrapper';
const instructionsTextarea = document.createElement('textarea');
instructionsTextarea.id = 'injector-textarea';
instructionsTextarea.placeholder = 'Paste your system instructions here...';
instructionsTextarea.value = isEditing ? profile.instructions : '';
const aiRefinerContainer = document.createElement('div');
aiRefinerContainer.className = 'ai-refiner-container-wrapper';
const aiRefinerButtons = document.createElement('div');
aiRefinerButtons.className = 'ai-refiner-buttons';
const prevBtn = document.createElement('button');
prevBtn.id = 'ai-refiner-prev-btn';
prevBtn.textContent = '‹';
prevBtn.title = 'Previous Version';
prevBtn.disabled = true;
const historyCounter = document.createElement('span');
historyCounter.id = 'ai-refiner-history-counter';
historyCounter.style.visibility = 'hidden'; // Hidden until there's history
const aiRefinerBtn = document.createElement('button');
aiRefinerBtn.id = 'ai-refiner-btn';
aiRefinerBtn.textContent = 'Refine with AI ✨';
const nextBtn = document.createElement('button');
nextBtn.id = 'ai-refiner-next-btn';
nextBtn.textContent = '›';
nextBtn.title = 'Next Version';
nextBtn.disabled = true;
const updateHistoryButtons = () => {
prevBtn.disabled = historyIndex <= 0;
nextBtn.disabled = historyIndex >= refinementHistory.length - 1;
if (refinementHistory.length > 1) {
historyCounter.textContent = `${historyIndex + 1} / ${refinementHistory.length}`;
historyCounter.style.visibility = 'visible';
} else {
historyCounter.style.visibility = 'hidden';
}
};
const guidanceTextarea = document.createElement('textarea');
guidanceTextarea.id = 'ai-refiner-guidance';
guidanceTextarea.placeholder = 'Optional: Guide the refinement (e.g., "make it more formal")';
guidanceTextarea.rows = 2;
const checkboxWrapper = document.createElement('div');
checkboxWrapper.className = 'checkbox-wrapper';
const contextCheckbox = document.createElement('input');
contextCheckbox.type = 'checkbox';
contextCheckbox.id = 'ai-refiner-context-checkbox';
const contextLabel = document.createElement('label');
contextLabel.htmlFor = 'ai-refiner-context-checkbox';
contextLabel.textContent = 'Use chat context';
checkboxWrapper.append(contextCheckbox, contextLabel);aiRefinerButtons.append(prevBtn, historyCounter, nextBtn);
aiRefinerButtons.append(prevBtn, aiRefinerBtn, nextBtn);
aiRefinerContainer.append(aiRefinerButtons, guidanceTextarea, checkboxWrapper);
instructionsContentWrapper.append(instructionsTextarea, aiRefinerContainer);
instructionsSection.append(instructionsSummary, instructionsContentWrapper);
const aiConfigSection = document.createElement('details');
aiConfigSection.className = 'ai-config-section';
// Ensure this section starts closed if the instructions section is open
aiConfigSection.open = false;
const summary = document.createElement('summary');
summary.textContent = 'AI Configuration';
const configContent = document.createElement('div');
configContent.className = 'ai-config-content';
const apiKeyLabel = document.createElement('label');
apiKeyLabel.htmlFor = 'gemini-api-key-input';
apiKeyLabel.textContent = 'Gemini API Key';
const apiKeyWrapper = document.createElement('div');
apiKeyWrapper.className = 'api-key-wrapper';
const apiKeyInput = document.createElement('input');
apiKeyInput.type = 'password';
apiKeyInput.id = 'gemini-api-key-input';
apiKeyInput.placeholder = 'Enter your key...';
const saveApiKeyBtn = document.createElement('button');
saveApiKeyBtn.id = 'save-api-key-btn';
saveApiKeyBtn.textContent = 'Save Key';
apiKeyWrapper.append(apiKeyInput, saveApiKeyBtn);
const modelLabel = document.createElement('label');
modelLabel.htmlFor = 'gemini-model-select';
modelLabel.textContent = 'LLM Model';
const modelSelect = document.createElement('select');
modelSelect.id = 'gemini-model-select';
AVAILABLE_MODELS.forEach(modelName => modelSelect.add(new Option(modelName, modelName)));
const metaPromptAccordionWithContext = document.createElement('details');
metaPromptAccordionWithContext.className = 'nested-accordion';
metaPromptAccordionWithContext.open = true; // Default to open
const metaPromptSummaryWithContext = document.createElement('summary');
metaPromptSummaryWithContext.textContent = 'Refiner Prompt (with context)';
const metaPromptTextareaWithContext = document.createElement('textarea');
metaPromptTextareaWithContext.id = 'meta-prompt-with-context';
metaPromptTextareaWithContext.className = 'meta-prompt-textarea';
metaPromptAccordionWithContext.append(metaPromptSummaryWithContext, metaPromptTextareaWithContext);
const metaPromptAccordionWithoutContext = document.createElement('details');
metaPromptAccordionWithoutContext.className = 'nested-accordion';
const metaPromptSummaryWithoutContext = document.createElement('summary');
metaPromptSummaryWithoutContext.textContent = 'Refiner Prompt (without context)';
const metaPromptTextareaWithoutContext = document.createElement('textarea');
metaPromptTextareaWithoutContext.id = 'meta-prompt-without-context';
metaPromptTextareaWithoutContext.className = 'meta-prompt-textarea';
metaPromptAccordionWithoutContext.append(metaPromptSummaryWithoutContext, metaPromptTextareaWithoutContext);
const savePromptsBtn = document.createElement('button');
savePromptsBtn.id = 'save-meta-prompts-btn';
savePromptsBtn.textContent = 'Save Refiner Prompts';
configContent.append(apiKeyLabel, apiKeyWrapper, modelLabel, modelSelect, metaPromptAccordionWithContext, metaPromptAccordionWithoutContext, savePromptsBtn);
aiConfigSection.append(summary, configContent);
const buttonsDiv = document.createElement('div');
buttonsDiv.className = 'injector-buttons';
const cancelButton = document.createElement('button');
cancelButton.id = 'injector-cancel';
cancelButton.textContent = 'Cancel';
const saveButton = document.createElement('button');
saveButton.id = 'injector-save';
saveButton.textContent = isEditing ? 'Save Changes' : 'Create Profile';
mainContent.append(h2, nameLabel, nameInput, iconLabelWrapper, iconPicker, instructionsSection, aiConfigSection);
buttonsDiv.append(cancelButton, saveButton);
modal.append(mainContent, buttonsDiv);
renderIcons();
nameInput.focus();
updateHistoryButtons(); // Set initial state of history UI
// Load existing values into the config section
const savedKey = await GM_getValue(STORAGE_KEY_GEMINI_API_KEY, null);
if (savedKey) {
apiKeyInput.placeholder = "Key is set. Enter a new key to overwrite.";
}
metaPromptTextareaWithContext.value = await GM_getValue(STORAGE_KEY_META_PROMPT_WITH_CONTEXT, DEFAULT_META_PROMPT_WITH_CONTEXT);
metaPromptTextareaWithoutContext.value = await GM_getValue(STORAGE_KEY_META_PROMPT_WITHOUT_CONTEXT, DEFAULT_META_PROMPT_WITHOUT_CONTEXT);
// --- Event Listeners ---
saveApiKeyBtn.addEventListener('click', async () => {
const newKey = apiKeyInput.value.trim();
if (newKey) {
await GM_setValue(STORAGE_KEY_GEMINI_API_KEY, newKey);
apiKeyInput.value = '';
apiKeyInput.placeholder = "Key is set. Enter a new key to overwrite.";
showToast("API Key saved successfully!", "success");
}
});
savePromptsBtn.addEventListener('click', async () => {
const withContext = metaPromptTextareaWithContext.value.trim();
const withoutContext = metaPromptTextareaWithoutContext.value.trim();
await GM_setValue(STORAGE_KEY_META_PROMPT_WITH_CONTEXT, withContext);
await GM_setValue(STORAGE_KEY_META_PROMPT_WITHOUT_CONTEXT, withoutContext);
showToast("Refiner prompts saved!", "success");
});
const handleAccordionToggle = (e) => {
if (e.target.open) {
if (e.target === instructionsSection) {
aiConfigSection.open = false;
} else if (e.target === aiConfigSection) {
instructionsSection.open = false;
}
}
};
instructionsSection.addEventListener('toggle', handleAccordionToggle);
aiConfigSection.addEventListener('toggle', handleAccordionToggle);
const handleNestedAccordionToggle = (e) => {
if (e.target.open) {
if (e.target === metaPromptAccordionWithContext) {
metaPromptAccordionWithoutContext.open = false;
} else if (e.target === metaPromptAccordionWithoutContext) {
metaPromptAccordionWithContext.open = false;
}
}
};
metaPromptAccordionWithContext.addEventListener('toggle', handleNestedAccordionToggle);
metaPromptAccordionWithoutContext.addEventListener('toggle', handleNestedAccordionToggle);
aiRefinerBtn.addEventListener('click', async () => {
aiRefinerBtn.disabled = true;
aiRefinerBtn.textContent = 'Refining...';
try {
// Before refining, ensure the current text is the latest in history
const currentText = instructionsTextarea.value;
if (currentText !== refinementHistory[historyIndex]) {
// User edited manually, so truncate future history
refinementHistory = refinementHistory.slice(0, historyIndex + 1);
refinementHistory.push(currentText);
historyIndex++;
}
const guidance = guidanceTextarea.value.trim();
const originalPrompt = instructionsTextarea.value;
const chatContext = contextCheckbox.checked ? await getChatContext() : '';
const selectedModel = modelSelect.value;
const refinedPrompt = await fetchRefinedPrompt({ originalPrompt, chatContext, guidance }, selectedModel);
// Add new version to history
refinementHistory.push(refinedPrompt);
historyIndex = refinementHistory.length - 1;
instructionsTextarea.value = refinedPrompt;
updateHistoryButtons();
showToast("Prompt refined successfully!", "success");
} catch (error) {
showToast(error.message, "error");
} finally {
aiRefinerBtn.disabled = false;
aiRefinerBtn.textContent = 'Refine with AI ✨';
}
});
prevBtn.addEventListener('click', () => {
if (historyIndex > 0) {
historyIndex--;
instructionsTextarea.value = refinementHistory[historyIndex];
updateHistoryButtons();
}
});
nextBtn.addEventListener('click', () => {
if (historyIndex < refinementHistory.length - 1) {
historyIndex++;
instructionsTextarea.value = refinementHistory[historyIndex];
updateHistoryButtons();
}
});
instructionsTextarea.addEventListener('input', () => {
const currentText = instructionsTextarea.value;
if (currentText !== refinementHistory[historyIndex]) {
refinementHistory = refinementHistory.slice(0, historyIndex + 1);
nextBtn.disabled = true; // Can't go forward anymore
}
});
importSvgBtn.addEventListener('click', async () => {
const buildInstruction = await showImportSVGModal();
if (buildInstruction) {
const iconId = 'custom_' + Date.now();
state.customIcons[iconId] = buildInstruction;
await saveCustomIcons();
showToast("Custom icon imported!", "success");
// Select the new icon and re-render the picker
selectedIcon = iconId;
renderIcons();
}
});
iconPicker.addEventListener('click', async (e) => {
const button = e.target.closest('#search-icon-btn');
if (!button) return;
try {
const selectedIconName = await showIconSearchModal();
if (selectedIconName) {
const svgCode = await fetchIconifySVG(selectedIconName);
const buildInstruction = parseSvgString(svgCode);
const iconId = 'custom_' + Date.now();
state.customIcons[iconId] = buildInstruction;
await saveCustomIcons();
showToast(`Icon "${selectedIconName}" imported!`, "success");
selectedIcon = iconId;
renderIcons();
}
} catch (error) {
showToast(error.message, 'error');
}
});
saveButton.addEventListener('click', () => {
const name = nameInput.value.trim();
const instructions = instructionsTextarea.value.trim();
if (name && instructions && selectedIcon) {
closeModal({ name, icon: selectedIcon, instructions });
} else {
showToast("Profile Name, Instructions, and a selected Icon are required.", "error");
}
});
cancelButton.addEventListener('click', () => {
closeModal(null);
});
// This listener handles closing the modal by clicking the background overlay.
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal(null);
});
});
}
async function showImportSVGModal() {
const { modal, closeModal } = await createBaseModal();
const h2 = document.createElement('h2');
h2.textContent = 'Import Custom Icon';
const p = document.createElement('p');
p.textContent = 'Paste your SVG code below. The script will securely parse it.';
modal.append(h2, p);
const textarea = document.createElement('textarea');
textarea.id = 'injector-textarea';
textarea.placeholder = '<svg viewBox="0 0 24 24">...</svg>';
const buttonsDiv = document.createElement('div');
buttonsDiv.className = 'injector-buttons';
const cancelButton = document.createElement('button');
cancelButton.className = 'cancel';
cancelButton.textContent = 'Cancel';
const importButton = document.createElement('button');
importButton.id = 'injector-save';
importButton.textContent = 'Import';
buttonsDiv.append(cancelButton, importButton);
modal.append(textarea, buttonsDiv);
textarea.focus();
return new Promise(resolve => {
importButton.addEventListener('click', () => {
try {
const instruction = parseSvgString(textarea.value);
closeModal(instruction).then(resolve);
} catch (error) {
showToast(error.message, 'error');
}
});
cancelButton.addEventListener('click', () => closeModal(null).then(resolve));
});
}
/**
* Securely parses an SVG string into a serializable "build instruction" object.
* @param {string} svgString - The raw SVG code string.
* @returns {{viewBox: string, children: Array<{tag: string, attrs: object}>}} The build instruction object.
*/
function parseSvgString(svgString) {
if (!svgString || !svgString.trim()) throw new Error("SVG code cannot be empty.");
// Use regex to extract the viewBox from the <svg> tag
const viewBoxMatch = svgString.match(/viewBox="([^"]+)"/);
const viewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 24 24';
const buildInstruction = { viewBox, children: [] };
// Regex to find all self-closing or container tags within the SVG
const tagRegex = /<([a-zA-Z0-9]+)\s*([^>]*?)\s*(\/>|>)/g;
let match;
while ((match = tagRegex.exec(svgString)) !== null) {
const tagName = match[1];
if (tagName.toLowerCase() === 'svg') continue; // Skip the root svg tag
const attributesString = match[2];
const childInstruction = { tag: tagName, attrs: {} };
// Regex to parse attributes (e.g., d="..." fill="#fff")
const attrRegex = /([a-zA-Z0-9\-:]+)="([^"]+)"/g;
let attrMatch;
while ((attrMatch = attrRegex.exec(attributesString)) !== null) {
// Exclude color attributes to allow CSS to take over
if (attrMatch[1] !== 'fill' && attrMatch[1] !== 'stroke') {
childInstruction.attrs[attrMatch[1]] = attrMatch[2];
}
}
buildInstruction.children.push(childInstruction);
}
if (buildInstruction.children.length === 0) {
throw new Error("Could not parse any child elements from the SVG string.");
}
return buildInstruction;
}
/**
* Shows a generic modal for single text input.
* @param {{title: string, message: string, placeholder?: string}} options
* @returns {Promise<string|null>} The user's input or null if cancelled.
*/
function showInputModal({ title, message, placeholder = '', modalId = 'injector-modal' }) {
return new Promise(resolve => {
const { modal, overlay, closeModalAndResolve } = createBaseModal(modalId);
const closeModal = (data) => {
closeModalAndResolve(resolve, data);
};
const h2 = document.createElement('h2');
h2.textContent = title;
const p = document.createElement('p');
p.textContent = message;
const input = document.createElement('input');
input.id = 'injector-input-name'; // Re-use style for consistency
input.placeholder = placeholder;
const buttonsDiv = document.createElement('div');
buttonsDiv.className = 'injector-buttons';
const cancelButton = document.createElement('button');
cancelButton.id = 'injector-cancel';
cancelButton.textContent = 'Cancel';
const submitButton = document.createElement('button');
submitButton.id = 'injector-save';
submitButton.textContent = 'Submit';
buttonsDiv.append(cancelButton, submitButton);
modal.append(h2, p, input, buttonsDiv);
input.focus();
submitButton.addEventListener('click', () => closeModal(input.value.trim()));
cancelButton.addEventListener('click', () => closeModal(null));
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(null); });
});
}
function showIconSearchModal() {
return new Promise(resolve => {
const { modal, overlay, closeModalAndResolve } = createBaseModal('injector-modal-search-results');
const closeModal = (data) => {
closeModalAndResolve(resolve, data);
};
const h2 = document.createElement('h2');
h2.textContent = 'Search Online Icons';
const searchContainer = document.createElement('div');
searchContainer.className = 'icon-search-container';
const input = document.createElement('input');
input.placeholder = "e.g., 'rocket', 'user'";
const searchButton = document.createElement('button');
searchButton.textContent = 'Search';
searchContainer.append(input, searchButton);
const iconGrid = document.createElement('div');
iconGrid.className = 'icon-search-results-grid';
const buttonsDiv = document.createElement('div');
buttonsDiv.className = 'injector-buttons';
const closeButton = document.createElement('button');
closeButton.id = 'injector-cancel';
closeButton.textContent = 'Close';
buttonsDiv.append(closeButton);
modal.append(h2, searchContainer, iconGrid, buttonsDiv);
input.focus();
const performSearch = async () => {
const query = input.value.trim();
if (!query) return;
searchButton.disabled = true;
searchButton.textContent = '...';
// Clear previous results and show loading message safely
iconGrid.textContent = '';
const loadingP = document.createElement('p');
loadingP.textContent = 'Loading...';
iconGrid.appendChild(loadingP);
try {
const iconNames = await searchIconify(query);
iconGrid.textContent = ''; // Clear loading message
if (iconNames.length === 0) {
const noResultsP = document.createElement('p');
noResultsP.textContent = 'No icons found for your query.';
iconGrid.appendChild(noResultsP);
return;
}
Promise.all(iconNames.map(async (name) => {
const svgText = await fetchIconifySVG(name);
const buildInstruction = parseSvgString(svgText);
// Per memory_bank.md, all icons must be built programmatically.
// createIconElement handles this requirement.
const iconElement = createIconElement(null, buildInstruction);
const button = document.createElement('button');
button.className = 'icon-picker-button';
button.dataset.iconName = name;
button.title = name;
button.appendChild(iconElement);
button.addEventListener('click', () => closeModal(name));
return button;
})).then(buttons => buttons.forEach(btn => btn && iconGrid.appendChild(btn)));
} catch (error) {
iconGrid.textContent = ''; // Clear loading message
const errorP = document.createElement('p');
errorP.className = 'error-message';
errorP.textContent = error.message;
iconGrid.appendChild(errorP);
} finally {
searchButton.disabled = false;
searchButton.textContent = 'Search';
}
};
searchButton.addEventListener('click', performSearch);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') performSearch(); });
closeButton.addEventListener('click', () => closeModal(null));
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(null); });
});
}
function showContextMenu(event, profile) {
return new Promise(resolve => {
const menu = document.createElement('div');
menu.id = 'injector-context-menu';
const defaultButton = document.createElement('button');
defaultButton.dataset.choice = 'default';
const isDefault = state.defaultProfileId === profile.id;
// Add a checkmark if this profile is the default
defaultButton.textContent = isDefault ? '✓ Default' : 'Set as Default';
const editButton = document.createElement('button');
editButton.dataset.choice = 'edit';
editButton.textContent = 'Edit';
const deleteButton = document.createElement('button');
deleteButton.dataset.choice = 'delete';
deleteButton.textContent = 'Delete';
menu.append(defaultButton);
menu.append(editButton, deleteButton);
document.body.appendChild(menu);
// Position the menu at the cursor
const { clientX: mouseX, clientY: mouseY } = event;
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
let top = mouseY;
let left = mouseX;
if (mouseX + menuWidth > screenWidth) left = screenWidth - menuWidth - 5;
if (mouseY + menuHeight > screenHeight) top = screenHeight - menuHeight - 5;
menu.style.top = `${top}px`;
menu.style.left = `${left}px`;
menu.classList.add('visible');
const handleGlobalClick = (e) => {
// This listener's job is to close the menu if the user clicks outside of it.
if (!menu.contains(e.target)) {
closeMenu(null);
}
};
const closeMenu = (choice = null) => {
menu.remove();
document.removeEventListener('click', handleGlobalClick, true);
resolve(choice);
};
menu.addEventListener('click', (e) => closeMenu(e.target.dataset.choice));
// Use setTimeout to add the listener after the current event loop, preventing it from catching the right-click that opened it.
setTimeout(() => document.addEventListener('click', handleGlobalClick, true), 0);
});
}
async function showConfirmationModal({ title, message }) {
return new Promise(resolve => {
const { modal, overlay, closeModalAndResolve } = createBaseModal();
const closeModal = (data) => {
closeModalAndResolve(resolve, data);
};
const h2 = document.createElement('h2');
h2.textContent = title;
const p = document.createElement('p');
p.textContent = message;
const buttonsDiv = document.createElement('div');
buttonsDiv.className = 'injector-buttons confirmation';
const cancelButton = document.createElement('button');
cancelButton.dataset.choice = 'false';
cancelButton.className = 'cancel';
cancelButton.textContent = 'Cancel';
const confirmButton = document.createElement('button');
confirmButton.dataset.choice = 'true';
confirmButton.className = 'confirm-delete';
confirmButton.textContent = 'Confirm';
buttonsDiv.append(cancelButton, confirmButton);
modal.append(h2, p, buttonsDiv);
buttonsDiv.addEventListener('click', (e) => {
if (e.target.dataset.choice) closeModal(e.target.dataset.choice === 'true');
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal(false);
});
});
}
/**
* Waits for a specific element to appear in the DOM.
* @param {string} selector The CSS selector for the element.
* @param {number} timeout The maximum time to wait in milliseconds.
* @returns {Promise<Element|null>} A promise that resolves with the element or null if timed out.
*/
function waitForElement(selector, timeout = TIMEOUTS.WAIT_FOR_ELEMENT) {
return new Promise(resolve => {
const existingElement = document.querySelector(selector);
if (existingElement) return resolve(existingElement);
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
if (element) {
clearTimeout(timer);
observer.disconnect();
resolve(element);
}
});
const timer = setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeout);
observer.observe(document.body, { childList: true, subtree: true });
});
}
/**
* Closes the system instructions panel by clicking the overlay backdrop.
* @returns {Promise<void>} A promise that resolves when the panel is closed.
*/
async function closeInstructionsPanel(wasSidebarInitiallyOpen) {
// Step 1: Wait for the modal's close button to appear and then click it.
// The modal is inside a `mat-dialog-container`. The close button has a specific aria-label.
const modalCloseButton = await waitForElement('mat-dialog-container button[aria-label="Close panel"]', TIMEOUTS.MODAL_UI_WAIT);
if (modalCloseButton) {
modalCloseButton.click();
// Wait for the modal's textarea to disappear before proceeding.
await waitForElementToDisappear(SELECTORS.systemInstructionsTextArea);
} else {
console.warn(`[${SCRIPT_NAME}] Could not find the modal close button to click.`);
}
// Step 2: Only close the main "Run settings" panel if the script opened it.
if (!wasSidebarInitiallyOpen) {
// We wait a brief moment for the UI to settle after the modal closes.
await new Promise(resolve => setTimeout(resolve, TIMEOUTS.UI_SETTLE));
const sidePanel = document.querySelector('ms-right-side-panel');
if (sidePanel) {
const panelCloseButton = sidePanel.querySelector('button[aria-label="Close run settings panel"]');
if (panelCloseButton) {
panelCloseButton.click();
await waitForElementToDisappear('ms-right-side-panel .settings-items-wrapper');
}
}
}
}
function waitForElementToDisappear(selector, timeout = TIMEOUTS.MODAL_UI_WAIT) {
return new Promise(resolve => {
if (!document.querySelector(selector)) {
return resolve();
}
const observer = new MutationObserver((mutations, obs) => {
if (!document.querySelector(selector)) {
clearTimeout(timer);
obs.disconnect();
resolve();
}
});
const timer = setTimeout(() => {
observer.disconnect();
console.warn(`[${SCRIPT_NAME}] waitForElementToDisappear timed out for selector: ${selector}`);
resolve(); // Resolve anyway to not block the script
}, timeout);
observer.observe(document.body, {
childList: true, subtree: true
});
});
}
async function injectText(text) {
// First, check if the sidebar is already open before we do anything.
const wasSidebarInitiallyOpen = !!document.querySelector('ms-right-side-panel .settings-items-wrapper');
let textArea = document.querySelector(SELECTORS.systemInstructionsTextArea);
// If the text area isn't visible, we need to open the panels.
if (!textArea) {
// 1. Ensure the main "Run settings" panel is open.
// We check the initial state again in case it was already open.
if (!wasSidebarInitiallyOpen) {
const openButton = await waitForElement(SELECTORS.runSettingsButton, TIMEOUTS.MODAL_UI_WAIT);
if (openButton) {
openButton.click();
}
}
// 2. Click the "System instructions" card within the panel.
const instructionsCard = await waitForElement(SELECTORS.systemInstructionsCard, TIMEOUTS.MODAL_UI_WAIT);
if (instructionsCard) {
instructionsCard.click();
// 3. Wait for the modal with the textarea to appear.
textArea = await waitForElement(SELECTORS.systemInstructionsTextArea, TIMEOUTS.MODAL_UI_WAIT);
}
}
if (textArea) {
// This sequence is crucial for React-based inputs to recognize the change.
textArea.value = text;
textArea.dispatchEvent(new Event('input', { bubbles: true }));
textArea.dispatchEvent(new Event('blur', { bubbles: true }));
await closeInstructionsPanel(wasSidebarInitiallyOpen);
} else {
showToast("Injection failed: could not find or open the instructions panel.", "error");
console.error(`[${SCRIPT_NAME}] Injection process failed. Could not find one of the required elements: Run Settings Button, Instructions Card, or Text Area.`);
}
}
function showToast(message, type = 'info') {
let container = document.getElementById('injector-toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'injector-toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `injector-toast ${type}`;
const iconDiv = document.createElement('div');
iconDiv.className = 'toast-icon';
const icons = { success: '✔', error: '✖', info: 'ℹ' };
iconDiv.textContent = icons[type] || 'ℹ';
const messageDiv = document.createElement('div');
messageDiv.textContent = message;
toast.append(iconDiv, messageDiv);
container.appendChild(toast);
setTimeout(() => toast.classList.add('fade-out'), 3500);
toast.addEventListener('animationend', () => toast.remove());
}
//================================================================================
// SECTION: Styling
//================================================================================
GM_addStyle(`
/* The invisible hover area at the bottom of the screen */
#profile-dock-trigger {
position: fixed;
bottom: 0;
left: 50%; /* Center the trigger */
transform: translateX(-50%); /* Adjust for its own width */
width: 800px; /* Match the max-width of the dock */
max-width: 100%; /* Ensure it doesn't overflow on small screens */
height: 10px;
z-index: 9980;
/* background: rgba(255, 0, 0, 0.3); */ /* DEBUG: Make trigger area visible */
}
#profile-dock-container {
position: fixed; bottom: 0; left: 0; width: 100%; z-index: 9981;
transform: translateY(100%); transition: transform 0.3s ease-in-out; pointer-events: none;
}
#profile-dock-container.visible { transform: translateY(0); }
#profile-dock {
display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; padding: 16px;
background: rgba(45, 45, 45, 0.9); backdrop-filter: blur(8px);
border-top: 1px solid #444; border-radius: 16px 16px 0 0;
max-width: 800px; margin: 0 auto; pointer-events: auto; /* Only the dock itself is interactive */
}
.profile-button {
width: 50px; height: 50px; border-radius: 50%; border: 2px solid #666; background-color: #333; color: #fff;
display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease;
}
.profile-button:hover { background-color: #444; border-color: #8e24aa; transform: translateY(-3px); }
.profile-button.active { border-color: #c0392b; background-color: #555; box-shadow: 0 0 15px rgba(231, 76, 60, 0.5); }
.profile-button svg { width: 24px; height: 24px; pointer-events: none; }
.profile-button.dragging { opacity: 0.5; border-style: dashed; }
.add-new-button { border-style: dashed; background-color: transparent; color: #888; font-size: 1.5em; }
.add-new-button:hover { background-color: #222; color: #fff; }
.dock-empty-state { color: #888; font-size: 0.9em; padding: 0 20px; text-align: center; }
/* Modal & Overlay Styles */
#injector-overlay, #injector-overlay-search-results { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7); z-index: 20000; backdrop-filter: blur(4px); }
#injector-modal {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: #2d2d2d; color: #e0e0e0; border-radius: 12px; padding: 24px;
width: 100vw; height: 100vh;
z-index: 20001; box-shadow: 0 10px 30px rgba(0,0,0,0.5); border: 1px solid #444;
display: flex; flex-direction: column;
max-height: 95vh; /* Prevent overflow on very short screens */
max-width: 95vw;
box-sizing: border-box;
}
#injector-modal-main-content {
flex: 1; /* This is the key: make this content area grow */
display: flex; flex-direction: column;
overflow-y: auto; /* Allow scrolling for content if it overflows */
}
#injector-modal-search-results { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #2d2d2d; color: #e0e0e0; border-radius: 12px; padding: 24px; width: 90%; max-width: 600px; z-index: 20001; box-shadow: 0 10px 30px rgba(0,0,0,0.5); border: 1px solid #444; display: flex; flex-direction: column; }
#injector-modal h2 { margin-top: 0; font-size: 1.4em; color: #fff; }
#injector-modal p { margin-bottom: 16px; line-height: 1.5; color: #ccc; }
#injector-modal-search-results p { text-align: center; color: #999; }
#injector-modal-search-results .error-message { color: #e74c3c; }
.icon-search-container { display: flex; gap: 8px; margin-bottom: 16px; }
.icon-search-container input { flex-grow: 1; background: #1e1e1e; color: #e0e0e0; border: 1px solid #555; border-radius: 8px; padding: 12px; font-family: sans-serif; font-size: 14px; box-sizing: border-box; }
.injector-label { display: block; margin: 16px 0 6px; font-weight: bold; color: #aaa; }
#injector-input-name, #injector-textarea, #ai-refiner-guidance { width: 100%; background: #1e1e1e; color: #e0e0e0; border: 1px solid #555; border-radius: 8px; padding: 12px; font-family: sans-serif; font-size: 14px; box-sizing: border-box; }
#injector-textarea { resize: vertical; font-family: monospace; }
.meta-prompt-textarea { width: 100%; background: #1e1e1e; color: #e0e0e0; border: 1px solid #555; border-radius: 8px; padding: 12px; font-family: sans-serif; font-size: 12px; box-sizing: border-box; min-height: 120px; resize: vertical; margin-bottom: 12px; }
.icon-picker-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 8px; margin-top: 8px; max-height: 150px; overflow-y: auto; padding: 4px; background: #222; border-radius: 6px; }
.icon-search-results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(50px, 1fr)); gap: 10px; margin-top: 16px; max-height: 40vh; overflow-y: auto; padding: 10px; background: #222; border-radius: 8px; }
.icon-picker-button { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; border: 2px solid #555; border-radius: 8px; background: #333; cursor: pointer; transition: all 0.2s ease; }
.icon-picker-button:hover { border-color: #777; }
.icon-picker-button.selected { border-color: #8e24aa; background: #4a2c55; }
.icon-picker-button svg { width: 20px; height: 20px; color: #ccc; }
.icon-picker-button.ai-generate { font-size: 1.5em; line-height: 1; border-style: dashed; border-color: #8e24aa; padding: 0; }
.icon-picker-button.ai-generate:hover { background: #4a2c55; }
.icon-picker-button[data-icon-name^="custom_"] { border-style: dashed; }
.custom-icon-wrapper { position: relative; }
.custom-icon-delete-btn {
position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%;
background: #c0392b; color: white; border: 1px solid #2d2d2d;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: bold; line-height: 1;
cursor: pointer; opacity: 0; transform: scale(0.5); transition: all 0.2s ease;
pointer-events: none;
}
.custom-icon-wrapper:hover .custom-icon-delete-btn { opacity: 1; transform: scale(1); pointer-events: auto; }
.custom-icon-delete-btn:hover { background: #e74c3c; }
.injector-buttons { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
.injector-buttons button { padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; transition: all 0.2s ease; }
#injector-cancel, .injector-buttons .cancel { background: #444; color: #fff; }
#injector-cancel:hover, .injector-buttons .cancel:hover { background: #555; }
#injector-save { background: #8e24aa; color: #fff; }
#injector-save:hover { background: #a042c0; }
.injector-buttons .delete, .injector-buttons .confirm-delete { background: #c0392b; color: #fff; }
.injector-buttons .delete:hover, .injector-buttons .confirm-delete:hover { background: #e74c3c; }
/* Label & Inline Button Styles */
.label-wrapper { display: flex; justify-content: space-between; align-items: center; }
.inline-button { background: none; border: none; color: #8e24aa; cursor: pointer; padding: 0; font-size: 0.9em; }
.inline-button:hover { text-decoration: underline; }
/* AI Refiner & Config Styles */
.ai-refiner-container-wrapper {
display: grid;
grid-template-columns: 1fr; /* This ensures a single-column layout, stacking children vertically. */
gap: 8px; /* Reduced gap for a tighter vertical layout */
padding: 8px;
background: #222;
border-radius: 6px;
}
.ai-refiner-buttons { display: flex; align-items: center; justify-content: center; gap: 8px; }
#ai-refiner-prev-btn, #ai-refiner-next-btn { background: #444; color: #fff; border: none; border-radius: 6px; cursor: pointer; width: 30px; height: 30px; font-size: 1.2em; line-height: 1; }
#ai-refiner-history-counter { font-size: 0.9em; color: #999; min-width: 40px; text-align: center; }
#ai-refiner-prev-btn:hover, #ai-refiner-next-btn:hover { background: #555; }
#ai-refiner-prev-btn:disabled, #ai-refiner-next-btn:disabled { opacity: 0.4; cursor: not-allowed; }
#ai-refiner-guidance { resize: vertical; font-size: 12px; padding: 6px 8px; }
#ai-refiner-btn { background: none; border: 1px solid #8e24aa; color: #8e24aa; font-weight: bold; padding: 6px 12px; border-radius: 6px; cursor: pointer; }
#ai-refiner-btn:not(:disabled):hover { background: #8e24aa; color: #fff; }
.checkbox-wrapper { display: flex; align-items: center; gap: 6px; }
.checkbox-wrapper label { margin: 0; font-weight: normal; color: #ccc; }
.instructions-section, .ai-config-section { margin-top: 20px; border: 1px solid #444; border-radius: 8px; padding: 0 12px; }
.instructions-section[open] { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.ai-config-section[open] { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.instructions-content-wrapper { flex: 1; display: flex; flex-direction: column; min-height: 0; gap: 12px; padding-bottom: 12px; }
.instructions-content-wrapper #injector-textarea { height: 100%; /* Allow it to fill the parent's height, which is managed by flexbox */ }
.instructions-content-wrapper .ai-refiner-container-wrapper { flex-shrink: 0; }
.instructions-section summary, .ai-config-section summary { padding: 12px 0; cursor: pointer; font-weight: bold; color: #aaa; }
.ai-config-content { padding-bottom: 12px; flex: 1; min-height: 0; overflow-y: auto; }
.ai-config-content label { display: block; margin-bottom: 6px; }
.ai-config-content label[for^="meta-prompt"] { margin-top: 16px; }
.api-key-wrapper { display: flex; gap: 8px; margin-bottom: 16px; }
.api-key-wrapper input { flex-grow: 1; margin: 0; }
.api-key-wrapper button, #save-meta-prompts-btn { background: #444; color: #fff; border: none; padding: 10px 15px; border-radius: 6px; cursor: pointer; width: auto; }
.api-key-wrapper button:hover { background: #555; }
/* Context Menu Styles */
#injector-context-menu { position: fixed; z-index: 20002; background: #3a3a3a; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.5); padding: 6px; display: flex; flex-direction: column; opacity: 0; transform: scale(0.95); transition: all 0.1s ease-out; }
#injector-context-menu.visible { opacity: 1; transform: scale(1); }
#injector-context-menu button { background: none; border: none; color: #e0e0e0; text-align: left; padding: 8px 12px; border-radius: 4px; cursor: pointer; width: 100%; }
#injector-context-menu button:hover { background: #8e24aa; }
#injector-context-menu button[data-choice="delete"]:hover { background: #c0392b; }
#injector-context-menu button[data-choice="default"]:hover { background: #3498db; }
/* Nested Accordion Styles */
.nested-accordion { margin-top: 16px; border: 1px solid #444; border-radius: 8px; padding: 0 12px; }
.nested-accordion summary { padding: 10px 0; cursor: pointer; font-weight: bold; color: #aaa; font-size: 0.9em; }
.nested-accordion .meta-prompt-textarea { margin-bottom: 12px; }
#gemini-model-select { width: 100%; background: #1e1e1e; color: #e0e0e0; border: 1px solid #555; border-radius: 8px; padding: 12px; font-family: sans-serif; font-size: 14px; box-sizing: border-box; margin-top: 6px; }
/* Toast Styles */
#injector-toast-container { position: fixed; top: 20px; right: 20px; z-index: 20003; display: flex; flex-direction: column; gap: 10px; }
.injector-toast { display: flex; align-items: center; padding: 12px 16px; border-radius: 8px; background: #333; color: #fff; border-left: 5px solid #555; box-shadow: 0 5px 15px rgba(0,0,0,0.4); animation: toast-fade-in 0.3s ease; min-width: 250px; }
.injector-toast.fade-out { animation: toast-fade-out 0.4s ease forwards; }
.injector-toast .toast-icon { margin-right: 12px; font-size: 1.2em; }
.injector-toast.success { border-left-color: #2ecc71; } .injector-toast.success .toast-icon { color: #2ecc71; }
.injector-toast.info { border-left-color: #3498db; } .injector-toast.info .toast-icon { color: #3498db; }
.injector-toast.error { border-left-color: #e74c3c; } .injector-toast.error .toast-icon { color: #e74c3c; }
@keyframes toast-fade-in { from { opacity: 0; transform: translateX(100%); } to { opacity: 1; transform: translateX(0); } }
@keyframes toast-fade-out { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(100%); } }
`);
//================================================================================
// KICK-OFF (with full function bodies)
//================================================================================
main();
})();