SillyTavern-Pathweaver/index.js
mattjaybe 5e23475b4c
v1.5.0 - Added Reasoning Mode
- New setting: Reasoning Mode.  Increases max tokens and properly handles thinking tags.
- Reasoning Mode allows for setting the max output token
2026-03-06 10:11:31 -05:00

4669 lines
221 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Pathweaver Extension - AI-powered story direction suggestions
// Uses SillyTavern.getContext() for stable API access (no ES6 imports)
(function () {
'use strict';
// ============================================================
// HOT RELOAD CLEANUP
// ============================================================
if (window.pathweaver_cleanup) {
try { window.pathweaver_cleanup(); } catch (e) { console.error('Pathweaver cleanup failed:', e); }
}
// ============================================================
// MODULE CONFIGURATION
// ============================================================
const DEBUG = false; // Set to true for development logging
/** Console prefix for streaming instrumentation - copy from DevTools to debug Connection Profile / Main API streaming */
const STREAM_LOG = '[Pathweaver:Stream]';
const MODULE_NAME = 'pathweaver';
const EXTENSION_NAME = 'Pathweaver';
// Get BASE_URL from script tag
const scripts = document.querySelectorAll('script[src*="index.js"]');
let BASE_URL = '';
for (const script of scripts) {
if (script.src.includes('Pathweaver')) {
BASE_URL = script.src.split('/').slice(0, -1).join('/');
break;
}
}
// Built-in category definitions
// Main toolbar categories
const MAIN_CATEGORIES = {
context: { name: 'Context-Aware', icon: 'fa-compass', tooltip: 'Context-based suggestions', builtin: true },
twist: { name: 'Plot Twist', icon: 'fa-shuffle', tooltip: 'Unexpected plot twists', builtin: true },
character: { name: 'New Character', icon: 'fa-user-plus', tooltip: 'Introduce characters', builtin: true },
explicit: { name: 'Explicit', icon: 'fa-fire', tooltip: 'NSFW content', builtin: true, nsfw: true }
};
// Genre specific categories (Dropdown)
const GENRE_CATEGORIES = {
action: { name: 'Action', icon: 'fa-person-running', tooltip: 'High energy and combat', builtin: true },
comedy: { name: 'Comedy', icon: 'fa-masks-theater', tooltip: 'Humor and levity', builtin: true },
fantasy: { name: 'Fantasy', icon: 'fa-hat-wizard', tooltip: 'Magic and wonder', builtin: true },
horror: { name: 'Horror', icon: 'fa-ghost', tooltip: 'Fear and dread', builtin: true },
mystery: { name: 'Mystery', icon: 'fa-magnifying-glass', tooltip: 'Puzzles and secrets', builtin: true },
noir: { name: 'Noir', icon: 'fa-user-secret', tooltip: 'Shadows and intrigue', builtin: true },
romance: { name: 'Romance', icon: 'fa-heart', tooltip: 'Love and affection', builtin: true },
'sci-fi': { name: 'Sci-Fi', icon: 'fa-rocket', tooltip: 'Futurism and tech', builtin: true },
thriller: { name: 'Thriller', icon: 'fa-stopwatch', tooltip: 'Suspense and pressure', builtin: true },
};
// Font Awesome icons for custom styles
// DEFAULT CATEGORY ICONS listed first so built-in styles always have their icon available
const AVAILABLE_ICONS = [
// ── Default icons used by built-in categories ──────────────────────
// Main: Context-Aware, Plot Twist, New Character, Explicit
'fa-compass', 'fa-shuffle', 'fa-user-plus', 'fa-fire',
// Genre: Action, Comedy, Fantasy, Horror, Mystery, Noir, Romance, Sci-Fi, Thriller
'fa-person-running', 'fa-masks-theater', 'fa-hat-wizard', 'fa-ghost',
'fa-magnifying-glass', 'fa-user-secret', 'fa-heart', 'fa-rocket', 'fa-stopwatch',
// ── General purpose ─────────────────────────────────────────────────
'fa-star', 'fa-bolt', 'fa-moon', 'fa-sun', 'fa-cloud', 'fa-leaf',
'fa-feather', 'fa-gem', 'fa-crown', 'fa-mask', 'fa-skull', 'fa-dragon',
'fa-wand-sparkles', 'fa-glasses', 'fa-dice', 'fa-puzzle-piece',
'fa-key', 'fa-lock', 'fa-book', 'fa-scroll', 'fa-map', 'fa-compass-drafting',
'fa-palette', 'fa-music', 'fa-film', 'fa-gamepad', 'fa-anchor',
// ── Extra icons ──────────────────────────────────────────────────────
'fa-scissors', 'fa-shield', 'fa-wand-magic', 'fa-eye', 'fa-brain',
'fa-tornado', 'fa-snowflake', 'fa-fire-flame-curved', 'fa-tree',
'fa-hand-fist', 'fa-hourglass-half', 'fa-spider', 'fa-cat',
'fa-chess-queen', 'fa-staff-snake'
];
/** Title font dropdown: value -> { fontFamily, label } for display and option styling */
const TITLE_FONT_OPTIONS = Object.freeze({
none: { fontFamily: 'inherit', label: 'None (hidden)' },
default: { fontFamily: "'Crimson Text', Georgia, serif", label: 'Default' },
crimson: { fontFamily: "'Crimson Text', Georgia, serif", label: 'Crimson Text' },
georgia: { fontFamily: "Georgia, 'Times New Roman', serif", label: 'Georgia' },
merriweather: { fontFamily: "'Merriweather', Georgia, serif", label: 'Merriweather' },
lora: { fontFamily: "'Lora', Georgia, serif", label: 'Lora' },
inter: { fontFamily: "'Inter', system-ui, sans-serif", label: 'Inter' },
nunito: { fontFamily: "'Nunito', system-ui, sans-serif", label: 'Nunito' },
poppins: { fontFamily: "'Poppins', system-ui, sans-serif", label: 'Poppins' },
roboto: { fontFamily: "'Roboto', system-ui, sans-serif", label: 'Roboto' }
});
function applyTitleFontSelectDisplay(selectEl) {
if (!selectEl || !selectEl.value) return;
const opt = TITLE_FONT_OPTIONS[selectEl.value];
if (opt) selectEl.style.fontFamily = opt.fontFamily;
}
// Default settings
const defaultSettings = Object.freeze({
enabled: true,
source: 'default',
preset: '',
ollama_url: 'http://localhost:11434',
ollama_model: '',
openai_url: 'http://localhost:1234/v1',
openai_model: 'local-model',
openai_preset: 'custom',
openai_key: '',
suggestions_count: 6,
context_depth: 4,
bar_minimized: false,
insert_mode: false,
insert_type_enabled: false,
insert_type_ooc: false,
insert_type_director: false,
show_explicit: false,
bar_font_size: 'default', // 'small', 'default', 'large'
bar_height: 'default', // 'compact', 'default', 'max'
bar_title_font: 'default', // 'none' (hidden) | 'default' | 'crimson'|'georgia'|'merriweather'|'lora' (serif) | 'inter'|'nunito'|'poppins'|'roboto' (sans)
suggestion_length: 'short', // 'short' (2-3 sentences) or 'long' (4-6 sentences)
stream_suggestions: false, // Stream generation per card (Ollama & OpenAI-compatible only)
include_scenario: true, // Include character scenario in context
include_description: true, // Include character description in context
include_worldinfo: false, // Include World Info lorebook in context
custom_styles: [],
hide_animated_bar: false,
surprise_depth_min: 2, // minimum messages away (used for random range or fixed min)
surprise_depth_max: 6, // maximum messages away (used for random range or fixed max)
surprise_randomize: true, // randomize depth between min and max
surprise_endless: false, // auto-rearm a new surprise after each one fires
reasoning_mode: false, // Enable reasoning mode for models like DeepSeek
max_output_tokens: 16384 // Configurable max output tokens (default 16K for reasoning models)
});
// Runtime state
let settings = JSON.parse(JSON.stringify(defaultSettings));
let actionBar = null;
let suggestionsModal = null;
let settingsModal = null;
let editorModal = null;
let abortController = null;
let isGenerating = false;
let promptCache = {};
let currentCategory = 'context';
let directorMode = 'single_scene'; // 'single_scene' or 'story_beats'
// Suggestion cache
let cachedSuggestions = {};
let cachedChatId = null;
// Surprise feature state
let activeSurprises = []; // array of { key, category, triggerAfter, baseMessageCount, text, injected }
let surpriseKeyCounter = 0; // increments per armed surprise for unique ST prompt keys
let surpriseAbortController = null;
// ============================================================
// LOGGING UTILITIES
// ============================================================
function log(...args) { if (DEBUG) console.log(`[${EXTENSION_NAME}]`, ...args); }
function warn(...args) { console.warn(`[${EXTENSION_NAME}]`, ...args); }
function error(...args) { console.error(`[${EXTENSION_NAME}]`, ...args); }
// ============================================================
// SETTINGS MANAGEMENT
// ============================================================
function getSettings() {
const { extensionSettings } = SillyTavern.getContext();
if (!extensionSettings[MODULE_NAME]) {
extensionSettings[MODULE_NAME] = JSON.parse(JSON.stringify(defaultSettings));
}
for (const key of Object.keys(defaultSettings)) {
if (!Object.hasOwn(extensionSettings[MODULE_NAME], key)) {
extensionSettings[MODULE_NAME][key] = defaultSettings[key];
}
}
return extensionSettings[MODULE_NAME];
}
function saveSettings() {
const { saveSettingsDebounced } = SillyTavern.getContext();
saveSettingsDebounced();
}
function loadSettings() {
settings = getSettings();
log('Settings loaded:', settings.enabled, settings.source);
}
// ============================================================
// CATEGORY HELPERS
// ============================================================
function getAllCategories() {
// Deep-copy so we can overlay customizations without mutating the originals
const categories = {};
for (const [k, v] of Object.entries(MAIN_CATEGORIES)) categories[k] = { ...v };
for (const [k, v] of Object.entries(GENRE_CATEGORIES)) categories[k] = { ...v };
// Apply saved icon customizations for built-in styles
if (settings.builtin_icon_customizations) {
for (const [id, icon] of Object.entries(settings.builtin_icon_customizations)) {
if (categories[id]) categories[id].icon = icon;
}
}
if (settings.custom_styles?.length) {
for (const style of settings.custom_styles) {
categories[style.id] = {
name: style.name,
icon: style.icon,
tooltip: style.name,
custom: true
};
}
}
return categories;
}
// Helper to get just the main bar buttons (Main + Custom)
function getBarButtons() {
const buttons = { ...MAIN_CATEGORIES };
// Inject customs after character, before explicit? Or just append.
// Let's just append custom styles to the main list logic.
// But we return them as a separate list for the UI builder loop
return buttons;
}
function getVisibleCategories() {
const all = getAllCategories();
const visible = {};
for (const [key, cat] of Object.entries(all)) {
if (cat.nsfw && !settings.show_explicit) continue;
visible[key] = cat;
}
return visible;
}
// ============================================================
// CONNECTION PROFILE UTILITIES (from EchoChamber pattern)
// ============================================================
/** Escape string for safe use in HTML attributes and text (e.g. profile names with quotes) */
function escapeHtmlAttr(str) {
if (str == null || typeof str !== 'string') return '';
return str
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function getConnectionProfiles() {
try {
const stContext = SillyTavern.getContext();
const connectionManager = stContext?.extensionSettings?.connectionManager;
if (connectionManager?.profiles?.length) {
log('Found', connectionManager.profiles.length, 'connection profiles');
return connectionManager.profiles;
}
log('No connection profiles found');
return [];
} catch (err) {
warn('Error getting connection profiles:', err);
return [];
}
}
function populateConnectionProfiles() {
// Target both the Settings Modal dropdown AND the Inline Drawer dropdown
const selectors = [jQuery('#pw_sm_profile'), jQuery('#pw_profile_select')];
try {
const profiles = getConnectionProfiles();
selectors.forEach(select => {
if (!select.length) return;
// Save current value to preserve selection during refresh
const currentValue = select.val() || settings.preset;
select.empty();
select.append('<option value="">-- Select Profile --</option>');
if (profiles.length) {
profiles.forEach(profile => {
const isSelected = currentValue === profile.name ? ' selected' : '';
const safeName = escapeHtmlAttr(profile.name);
select.append(`<option value="${safeName}"${isSelected}>${safeName}</option>`);
});
} else {
select.append('<option value="" disabled>No profiles found</option>');
}
// Restore value if it exists in the new list
if (currentValue && profiles.some(p => p.name === currentValue)) {
select.val(currentValue);
}
});
log('Populated connection profiles:', profiles.length);
} catch (err) {
warn('Error loading connection profiles:', err);
selectors.forEach(select => {
if (!select.length) return;
select.append('<option value="" disabled>Error loading profiles</option>');
});
}
}
// ============================================================
// OLLAMA UTILITIES
// ============================================================
async function fetchOllamaModels() {
try {
const baseUrl = (settings.ollama_url || 'http://localhost:11434').replace(/\/$/, '');
const response = await fetch(`${baseUrl}/api/tags`, { method: 'GET' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
log('Ollama models:', data.models?.length || 0);
return data.models || [];
} catch (err) {
warn('Failed to fetch Ollama models:', err.message);
return [];
}
}
// ============================================================
// PROMPT LOADING
// ============================================================
async function loadPrompt(category) {
if (promptCache[category]) {
return promptCache[category];
}
// Check for user customization of built-in style
if (settings.builtin_customizations?.[category]) {
promptCache[category] = settings.builtin_customizations[category];
return settings.builtin_customizations[category];
}
const customStyle = settings.custom_styles?.find(s => s.id === category);
if (customStyle) {
const prompt = (customStyle.prompt && String(customStyle.prompt).trim()) ? customStyle.prompt : null;
if (prompt) {
promptCache[category] = prompt;
return prompt;
}
warn(`Custom style "${category}" has no prompt; using template.`);
try {
const templateResp = await fetch(`${BASE_URL}/prompts/template.md?v=${Date.now()}`);
const fallback = templateResp.ok ? await templateResp.text() : 'Generate story suggestions.';
promptCache[category] = fallback;
return fallback;
} catch (_) {
promptCache[category] = 'Generate story suggestions.';
return 'Generate story suggestions.';
}
}
try {
const response = await fetch(`${BASE_URL}/prompts/${category}.md?v=${Date.now()}`);
if (!response.ok) throw new Error('Failed to load prompt');
const prompt = await response.text();
promptCache[category] = prompt;
return prompt;
} catch (err) {
warn(`Failed to load prompt for ${category}:`, err);
const templateResp = await fetch(`${BASE_URL}/prompts/template.md?v=${Date.now()}`);
return templateResp.ok ? await templateResp.text() : 'Generate story suggestions.';
}
}
async function loadTemplatePrompt() {
try {
const response = await fetch(`${BASE_URL}/prompts/template.md?v=${Date.now()}`);
if (!response.ok) throw new Error('Failed');
return await response.text();
} catch {
return `You are a creative writing assistant generating story direction suggestions.
TASK: Generate distinct suggestions for what could happen next in the narrative.
OUTPUT FORMAT:
[EMOJI] TITLE
DESCRIPTION
---
GUIDELINES:
- Each suggestion should be distinct and creative
- Keep titles punchy and evocative (under 8 words)
- Match the tone and genre of the ongoing story
- Do NOT include numbering or preamble`;
}
}
// ============================================================
// CONTEXT EXTRACTION
// ============================================================
function extractContext() {
const stContext = SillyTavern.getContext();
const context = stContext;
const chat = stContext?.chat;
if (!chat || chat.length === 0) return null;
// Helper to strip reasoning/thinking tags from text
const stripReasoningTags = (text) => {
if (!text) return '';
return text
.replace(/<(thought|think|thinking|reasoning|reason)>[\s\S]*?<\/\1>/gi, '')
.replace(/<(thought|think|thinking|reasoning|reason)\/>/gi, '')
.replace(/<(thought|think|thinking|reasoning|reason)\s*\/>/gi, '')
.trim();
};
const cleanMessage = (text) => {
if (!text) return '';
let cleaned = stripReasoningTags(text);
cleaned = cleaned.replace(/<[^>]*>/g, '');
const txt = document.createElement('textarea');
txt.innerHTML = cleaned;
return txt.value.substring(0, 10000);
};
const depth = Math.max(2, Math.min(10, settings.context_depth || 4));
const recentMessages = chat.slice(-depth);
const history = recentMessages.map(msg =>
`${msg.name}: ${cleanMessage(msg.mes)}`
).join('\n\n');
let characterInfo = '';
let scenario = '';
let description = '';
let worldInfo = '';
if (stContext.characterId !== undefined && stContext.characters && stContext.characters[stContext.characterId]) {
const char = stContext.characters[stContext.characterId];
characterInfo = `Character: ${char.name || 'Unknown'}`;
if (char.data?.scenario) scenario = char.data.scenario;
else if (char.scenario) scenario = char.scenario;
if (char.data?.description) description = char.data.description;
else if (char.description) description = char.description;
}
// Extract World Info / Lorebook entries with Order >= 250 filter
try {
const entries = [];
const MIN_ORDER = 250;
// Helper to process WI entries from various formats
const processEntries = (entryData) => {
if (!entryData) return;
const entryList = Array.isArray(entryData) ? entryData : Object.values(entryData);
for (const entry of entryList) {
if (!entry) continue;
const content = entry.content || entry.text || '';
const isDisabled = entry.disable === true || entry.disabled === true;
const order = entry.order ?? entry.insertion_order ?? 0;
if (content && !isDisabled && order >= MIN_ORDER) {
entries.push(content);
}
}
};
// Method 1: Character's embedded lorebook (primary source)
if (stContext.characterId !== undefined && stContext.characters && stContext.characters[stContext.characterId]) {
const char = stContext.characters[stContext.characterId];
if (char.data?.character_book?.entries) processEntries(char.data.character_book.entries);
if (entries.length === 0 && char.character_book?.entries) processEntries(char.character_book.entries);
}
// Method 2: Global window.world_info
if (entries.length === 0 && typeof window.world_info !== 'undefined' && window.world_info) {
processEntries(window.world_info);
if (window.world_info.entries) processEntries(window.world_info.entries);
}
// Method 3: window.world_info_data
if (entries.length === 0 && window.world_info_data?.entries) processEntries(window.world_info_data.entries);
// Method 4: chatMetadata.worldInfo
if (entries.length === 0 && stContext.chatMetadata?.worldInfo) processEntries(stContext.chatMetadata.worldInfo);
if (entries.length > 0) worldInfo = entries.slice(0, 10).join('\n\n');
} catch (err) {
warn('Failed to extract World Info:', err);
}
return {
history,
characterInfo,
scenario,
description,
worldInfo,
messageCount: recentMessages.length,
chatId: stContext.chatId || Date.now()
};
}
// ============================================================
// GENERATION LOGIC (Pattern from EchoChamber)
// ============================================================
async function generateSuggestions(category, forceRefresh = false, customDirections = null, mode = 'single_scene', outputContainer = null) {
log('Generating suggestions for:', category);
const stContext = SillyTavern.getContext();
const context = stContext;
if (!stContext) {
error('SillyTavern context not available');
return;
}
const storyContext = extractContext();
if (!storyContext) {
showEmptyState('Start a conversation to get suggestions', outputContainer);
return;
}
// Only cache if NOT director mode (director is always dynamic)
if (category !== 'director') {
if (cachedChatId !== storyContext.chatId) {
cachedSuggestions = {};
cachedChatId = storyContext.chatId;
}
if (!forceRefresh && cachedSuggestions[category]) {
displaySuggestions(cachedSuggestions[category], category, outputContainer);
return;
}
}
if (isGenerating) return;
isGenerating = true;
currentCategory = category;
// Determine loading message
let loadingMsg = 'Generating Suggestions...';
showLoadingState(category, outputContainer, loadingMsg);
abortController = new AbortController();
try {
let categoryPrompt = await loadPrompt(category);
// Perform macro substitution ({{user}}, {{char}})
const charName = storyContext.characterInfo.replace('Character: ', '') || 'Character';
const userName = stContext.name1 || 'User';
categoryPrompt = categoryPrompt
.replace(/{{char}}/g, charName)
.replace(/{{user}}/g, userName)
.replace(/{{model}}/g, charName); // some prompts use model as char alias
let contextBlock = '';
if (storyContext.characterInfo) contextBlock += `${storyContext.characterInfo}\n\n`;
if (settings.include_scenario && storyContext.scenario) contextBlock += `Scenario: ${storyContext.scenario}\n\n`;
if (settings.include_description && storyContext.description) {
contextBlock += `Character Description: ${storyContext.description.substring(0, 10000)}\n\n`;
}
if (settings.include_worldinfo && storyContext.worldInfo) {
contextBlock += `World Lore:\n${storyContext.worldInfo.substring(0, 10000)}\n\n`;
}
contextBlock += `Recent conversation:\n${storyContext.history}`;
let userPrompt = '';
let calculatedMaxTokens = 0;
// Calculate base tokens needed for suggestions
const tokensPerSuggestion = settings.suggestion_length === 'long' ? 400 : 280;
const baseTokensNeeded = settings.suggestions_count * tokensPerSuggestion + 800;
if (settings.reasoning_mode) {
// Reasoning mode: use configurable max_output_tokens, but ensure it's at least enough for suggestions
// Reasoning models need more tokens for their thinking process
calculatedMaxTokens = Math.max(settings.max_output_tokens || 8192, baseTokensNeeded);
log(`Reasoning mode enabled. Using max_tokens: ${calculatedMaxTokens} (config: ${settings.max_output_tokens})`);
} else {
// Normal mode: use the original formula with reasonable bounds
calculatedMaxTokens = Math.min(8192, Math.max(2048, baseTokensNeeded));
}
if (category === 'director' && customDirections?.length) {
if (mode === 'story_beats') {
// Story Beats: 1 input = 1 suggestion (Classic behavior)
const dirList = customDirections.map((d, i) => `${i + 1}. ${d}`).join('\n');
userPrompt = `[STORY CONTEXT]\n${contextBlock}\n\n[TASK]\nGenerate exactly ${customDirections.length} suggestions, one for each of the following directions.\n\nUSER DIRECTIONS:\n${dirList}\n\nFORMAT:\n[EMOJI] TITLE\nDESCRIPTION\n\nGUIDELINES:\n- PREVENT BLEED: Each suggestion must be strictly isolated to its corresponding input beat. Do NOT combine events from different beats unless explicitly requested.\n- Follow the specific direction for each suggestion EXACTLY.\n- Keep titles punchy and plain text (no asterisks).\n- ${settings.suggestion_length === 'long' ? 'Write 4-6 sentences per suggestion.' : 'Write 2-3 sentences per suggestion.'}\n- Do NOT include any preamble.${settings.stream_suggestions ? '\n\nSTREAMING: Output one complete suggestion at a time. Each suggestion MUST start with [EMOJI] TITLE then DESCRIPTION; end each with --- before the next. Do NOT repeat a title or copy content from one suggestion into another. Every suggestion is independent and self-contained.' : ''}`;
// Recalculate for director mode with custom directions
const dirTokensNeeded = customDirections.length * tokensPerSuggestion + 800;
if (settings.reasoning_mode) {
calculatedMaxTokens = Math.max(settings.max_output_tokens || 8192, dirTokensNeeded);
} else {
calculatedMaxTokens = Math.min(8192, Math.max(2048, dirTokensNeeded));
}
} else {
// Single Scene: Combined inputs = N suggestions (New behavior)
const combinedDirections = customDirections.join(' ');
const lengthInstruction = settings.suggestion_length === 'long'
? 'Each description should be 4-6 sentences, providing rich detail and context.'
: 'Each description should be 2-3 sentences, concise but evocative.';
userPrompt = `[STORY CONTEXT]\n${contextBlock}\n\n[TASK]\nThe user has provided the following direction/scenario for the next scene:\n"${combinedDirections}"\n\nBased on this direction, generate exactly ${settings.suggestions_count} DISTINCT options or variations for how this scene could play out.\n${lengthInstruction}\n\nFORMAT:\n[EMOJI] TITLE\nDESCRIPTION\n\nGUIDELINES:\n- All suggestions must follow the user's direction but offer different execution/flavor.\n- Keep titles punchy and plain text.\n- Do NOT include any preamble.${settings.stream_suggestions ? '\n\nSTREAMING: Output one complete suggestion at a time. Each suggestion MUST start with [EMOJI] TITLE then DESCRIPTION; end each with --- before the next. Do NOT repeat a title or copy content from one suggestion into another. Every suggestion is independent and self-contained.' : ''}`;
// Recalculate for director single scene mode
if (settings.reasoning_mode) {
calculatedMaxTokens = Math.max(settings.max_output_tokens || 8192, baseTokensNeeded);
} else {
calculatedMaxTokens = Math.min(8192, Math.max(2048, baseTokensNeeded));
}
}
} else {
const lengthInstruction = settings.suggestion_length === 'long'
? 'Each description should be 4-6 sentences, providing rich detail and context.'
: 'Each description should be 2-3 sentences, concise but evocative.';
userPrompt = `[STORY CONTEXT]\n${contextBlock}\n\n[TASK]\nGenerate exactly ${settings.suggestions_count} distinct suggestions.\n${lengthInstruction}\nFollow the format specified in the system instructions exactly.\nIMPORTANT: Use PLAIN TEXT for titles - do NOT wrap titles in **asterisks**.\nDo NOT include any preamble.${settings.stream_suggestions ? '\n\nSTREAMING: Output one complete suggestion at a time. Each suggestion MUST start with [EMOJI] TITLE then DESCRIPTION; end each with --- before the next. Do NOT repeat a title or copy content from one suggestion into another. Every suggestion is independent and self-contained.' : ''}`;
// Use the pre-calculated baseTokensNeeded (already calculated above)
if (settings.reasoning_mode) {
calculatedMaxTokens = Math.max(settings.max_output_tokens || 8192, baseTokensNeeded);
} else {
calculatedMaxTokens = Math.min(8192, Math.max(2048, baseTokensNeeded));
}
}
let result = '';
log(`Calculated Max Tokens: ${calculatedMaxTokens}`);
// Fail fast: avoid silently falling back to default API when profile is selected but none chosen
if (settings.source === 'profile') {
if (!settings.preset || !String(settings.preset).trim()) {
throw new Error('Please select a connection profile');
}
}
// Streaming path: Ollama & OpenAI always; Profile & Default try stream then fallback to non-streaming
if (settings.stream_suggestions) {
if (settings.source === 'ollama' || settings.source === 'openai') {
try {
await runStreamingGeneration({
source: settings.source,
categoryPrompt,
userPrompt,
calculatedMaxTokens,
category,
outputContainer,
abortController
});
} catch (err) {
if (err.name === 'AbortError' || (abortController && abortController.signal.aborted)) {
showEmptyState('Generation cancelled by user', outputContainer);
} else {
error('Streaming generation failed:', err);
showErrorState(err.message || 'API request failed', outputContainer);
}
} finally {
isGenerating = false;
abortController = null;
}
return;
}
if (settings.source === 'profile' || settings.source === 'default') {
let streamSucceeded = false;
try {
await runStreamingGeneration({
source: settings.source,
categoryPrompt,
userPrompt,
calculatedMaxTokens,
category,
outputContainer,
abortController
});
streamSucceeded = true;
} catch (err) {
if (err.name === 'AbortError' || (abortController && abortController.signal.aborted)) {
showEmptyState('Generation cancelled by user', outputContainer);
isGenerating = false;
abortController = null;
return;
}
console.log(STREAM_LOG, 'Fallback to non-streaming:', err?.message || String(err));
console.log(STREAM_LOG, 'Stack:', err?.stack);
}
if (streamSucceeded) {
isGenerating = false;
abortController = null;
return;
}
// Fall through to non-streaming path below
}
}
if (settings.source === 'profile' && settings.preset) {
const cm = stContext.extensionSettings?.connectionManager;
const profile = cm?.profiles?.find(p => p.name === settings.preset);
if (!profile) throw new Error(`Profile '${settings.preset}' not found`);
if (!stContext.ConnectionManagerRequestService) throw new Error('ConnectionManagerRequestService not available');
const messages = [
{ role: 'system', content: categoryPrompt },
{ role: 'user', content: userPrompt }
];
log(`Generating with profile: ${profile.name}`);
const response = await stContext.ConnectionManagerRequestService.sendRequest(
profile.id,
messages,
calculatedMaxTokens,
{
stream: false,
signal: abortController.signal,
extractData: true,
includePreset: true,
includeInstruct: true
}
);
if (response?.content) result = response.content;
else if (typeof response === 'string') result = response;
else if (response?.choices?.[0]?.message?.content) result = response.choices[0].message.content;
else result = JSON.stringify(response);
} else if (settings.source === 'ollama') {
const baseUrl = (settings.ollama_url || 'http://localhost:11434').replace(/\/$/, '');
if (!settings.ollama_model) throw new Error('No Ollama model selected');
log(`Generating with Ollama: ${settings.ollama_model}`);
const response = await fetch(`${baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: settings.ollama_model,
system: categoryPrompt,
prompt: userPrompt,
stream: false,
options: { num_ctx: 8192, num_predict: calculatedMaxTokens }
}),
signal: abortController.signal
});
if (!response.ok) throw new Error(`Ollama API error: ${response.status}`);
const data = await response.json();
result = data.response || '';
} else if (settings.source === 'openai') {
const baseUrl = (settings.openai_url || 'http://localhost:1234/v1').replace(/\/$/, '');
log(`Generating with OpenAI-compatible: ${baseUrl}`);
const headers = { 'Content-Type': 'application/json' };
if (settings.openai_key) {
headers['Authorization'] = `Bearer ${settings.openai_key}`;
}
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: headers,
body: JSON.stringify({
model: settings.openai_model || 'local-model',
messages: [
{ role: 'system', content: categoryPrompt },
{ role: 'user', content: userPrompt }
],
temperature: 0.8,
max_tokens: calculatedMaxTokens,
stream: false
}),
signal: abortController.signal
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
const data = await response.json();
result = data.choices?.[0]?.message?.content || '';
} else {
const { generateRaw } = stContext;
if (!generateRaw) throw new Error('generateRaw not available in context');
log('Generating with default ST API');
// Create a promise that rejects when aborted
const abortPromise = new Promise((_, reject) => {
abortController.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
});
// Race the generation against the abort signal
result = await Promise.race([
generateRaw({ systemPrompt: categoryPrompt, prompt: userPrompt, streaming: false }),
abortPromise
]);
}
if (abortController.signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const suggestions = await parseSuggestions(result);
if (category !== 'director') cachedSuggestions[category] = suggestions;
displaySuggestions(suggestions, category, outputContainer);
} catch (err) {
if (err.name === 'AbortError' || (abortController && abortController.signal.aborted)) {
showEmptyState('Generation cancelled by user', outputContainer);
} else {
error('Generation failed:', err);
showErrorState(err.message || 'API request failed', outputContainer);
}
} finally {
isGenerating = false;
abortController = null;
}
}
// ============================================================
// RESPONSE PARSING - Robust multi-strategy parser
// ============================================================
async function parseSuggestions(text) {
// Yield to UI thread to prevent blocking during parsing
await new Promise(resolve => setTimeout(resolve, 0));
if (!text) return [];
// First, strip any reasoning/thinking tags from the entire response
// Handles both XML-style (DeepSeek, etc.) and HTML-style tags
let cleanedText = text
// XML-style reasoning tags: <think>, </think>, <thinking>, </thinking>, etc.
.replace(/<\/?(thought|think|thinking|reasoning|reason)\s*[^>]*>/gi, '')
// HTML-style reasoning tags
.replace(/<(thought|think|thinking|reasoning|reason)>[\s\S]*?<\/\1>/gi, '')
.replace(/<(thought|think|thinking|reasoning|reason)\/>/gi, '')
.replace(/<(thought|think|thinking|reasoning|reason)\s*\/>/gi, '')
.trim();
const suggestions = [];
let blocks = [];
// Broad emoji pattern that catches most emojis including extended ranges
const emojiRegex = /[\u{1F000}-\u{1F02B}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{2300}-\u{23FF}\u{2B00}-\u{2BFF}\u{2B50}\u{1FA00}-\u{1FAFF}]/gu;
// Strategy 1: Split by --- separator (various formats)
blocks = cleanedText.split(/\n---\n|\n---|---\n|\n\n---\n\n/);
// Strategy 2: Split by double newlines (common format)
if (blocks.length <= 1) {
blocks = cleanedText.split(/\n\n+/);
}
// Strategy 3: If still few blocks, try to find emoji patterns anywhere
if (blocks.length <= 2) {
// Find all emojis in the text and use them as split points
const emojiMatches = [...cleanedText.matchAll(emojiRegex)];
if (emojiMatches.length >= 2) {
blocks = [];
for (let i = 0; i < emojiMatches.length; i++) {
const start = emojiMatches[i].index;
const end = i < emojiMatches.length - 1 ? emojiMatches[i + 1].index : cleanedText.length;
const block = cleanedText.substring(start, end).trim();
if (block.length > 10) {
blocks.push(block);
}
}
}
}
// Strategy 4: Split by numbered patterns like "1." or "1)" at line start
if (blocks.length <= 2) {
const numberedBlocks = cleanedText.split(/\n(?=\d+[\.\)]\s)/);
if (numberedBlocks.length > blocks.length) {
blocks = numberedBlocks;
log('Strategy 4 (numbered) found', blocks.length, 'blocks');
}
}
for (const block of blocks) {
let trimmed = block.trim();
if (!trimmed || trimmed.length < 10) continue;
// Strip any remaining reasoning tags from this block
trimmed = trimmed
// XML-style reasoning tags
.replace(/<\/?(thought|think|thinking|reasoning|reason)\s*[^>]*>/gi, '')
// HTML-style reasoning tags
.replace(/<(thought|think|thinking|reasoning|reason)>[\s\S]*?<\/\1>/gi, '')
.replace(/<[^>]*>/g, '')
.trim();
if (!trimmed || trimmed.length < 10) continue;
// Find the first emoji in this block
const emojiMatch = trimmed.match(emojiRegex);
let emoji = '✨';
let title = '';
let description = '';
if (emojiMatch) {
emoji = emojiMatch[0];
const emojiIndex = trimmed.indexOf(emoji);
// Get text after emoji as title (first line or until next newline)
const afterEmoji = trimmed.substring(emojiIndex + emoji.length).trim();
const newlineIndex = afterEmoji.indexOf('\n');
if (newlineIndex > 0) {
title = afterEmoji.substring(0, newlineIndex).trim();
description = afterEmoji.substring(newlineIndex + 1).trim();
} else {
title = afterEmoji;
description = '';
}
} else {
// No emoji, just use first line as title
const lines = trimmed.split('\n');
title = lines[0].trim();
description = lines.slice(1).join(' ').trim();
}
// Remove leading numbers like "1." or "1)"
title = title.replace(/^\d+[\.\)]\s*/, '');
// Strip markdown formatting from title
title = title.replace(/\*\*([^*]+)\*\*/g, '$1');
title = title.replace(/\*([^*]+)\*/g, '$1');
title = title.replace(/^\*+\s*|\s*\*+$/g, '').trim();
title = title.replace(/\s+/g, ' ');
// Strip markdown from description
description = description
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/\s+/g, ' ')
.trim();
if (title && title.length > 2 && title.length < 150) {
suggestions.push({
emoji,
title: title.substring(0, 100),
description: description || 'Click to use this suggestion'
});
}
}
log('Parsed', suggestions.length, 'suggestions');
return suggestions.slice(0, settings.suggestions_count);
}
// ============================================================
// STREAMING: incremental parse and UI
// ============================================================
const emojiRegexStream = /[\u{1F000}-\u{1F02B}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{2300}-\u{23FF}\u{2B00}-\u{2BFF}\u{2B50}\u{1FA00}-\u{1FAFF}]/gu;
/** Parse a single suggestion block into { emoji, title, description } or null */
function parseOneBlock(blockText) {
if (!blockText || typeof blockText !== 'string') return null;
let trimmed = blockText
// XML-style reasoning tags
.replace(/<\/?(thought|think|thinking|reasoning|reason)\s*[^>]*>/gi, '')
// HTML-style reasoning tags
.replace(/<(thought|think|thinking|reasoning|reason)>[\s\S]*?<\/\1>/gi, '')
.replace(/<[^>]*>/g, '')
.trim();
if (trimmed.length < 10) return null;
let emoji = '✨';
let title = '';
let description = '';
const emojiMatch = trimmed.match(emojiRegexStream);
if (emojiMatch) {
emoji = emojiMatch[0];
const emojiIndex = trimmed.indexOf(emoji);
const afterEmoji = trimmed.substring(emojiIndex + emoji.length).trim();
const newlineIndex = afterEmoji.indexOf('\n');
if (newlineIndex > 0) {
title = afterEmoji.substring(0, newlineIndex).trim();
description = afterEmoji.substring(newlineIndex + 1).trim();
} else {
title = afterEmoji;
}
} else {
const lines = trimmed.split('\n');
title = lines[0].trim();
description = lines.slice(1).join(' ').trim();
}
title = title.replace(/^\d+[\.\)]\s*/, '');
title = title.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\*([^*]+)\*/g, '$1').replace(/^\*+\s*|\s*\*+$/g, '').trim().replace(/\s+/g, ' ');
description = description.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\*([^*]+)\*/g, '$1').replace(/\s+/g, ' ').trim();
if (!title || title.length < 2 || title.length > 150) return null;
return {
emoji,
title: title.substring(0, 100),
description: description || 'Click to use this suggestion'
};
}
/** Split streamed buffer into complete blocks and current partial (for --- or double newline) */
function splitStreamBuffer(buffer) {
if (!buffer || !buffer.trim()) return { completeBlocks: [], partial: '' };
let cleaned = buffer
.replace(/<(thought|think|thinking|reasoning|reason)>[\s\S]*?<\/\1>/gi, '')
.replace(/<(thought|think|thinking|reasoning|reason)\/>/gi, '')
.trim();
const bySeparator = cleaned.split(/\n---\n|\n---|---\n|\n\n---\n\n/);
if (bySeparator.length > 1) {
const completeBlocks = bySeparator.slice(0, -1).map(s => s.trim()).filter(s => s.length >= 10);
const partial = bySeparator[bySeparator.length - 1].trim();
return { completeBlocks, partial };
}
const byDoubleNewline = cleaned.split(/\n\n+/);
if (byDoubleNewline.length > 1) {
const completeBlocks = byDoubleNewline.slice(0, -1).map(s => s.trim()).filter(s => s.length >= 10);
const partial = byDoubleNewline[byDoubleNewline.length - 1].trim();
return { completeBlocks, partial };
}
return { completeBlocks: [], partial: cleaned };
}
function showStreamingState(outputContainer, category) {
const body = outputContainer || jQuery('#pw_modal_body');
const allCategories = getAllCategories();
const catName = allCategories[category]?.name || category;
const count = Math.max(1, Math.min(12, Number(settings.suggestions_count) || 6));
const cardsHtml = Array.from({ length: count }, (_, i) => {
const isFirst = i === 0;
const stateClass = isFirst ? 'pw_streaming_active' : 'pw_streaming_waiting';
const title = isFirst ? 'Streaming…' : `Suggestion ${i + 1}`;
const desc = isFirst ? 'First suggestion is being generated…' : (i === 1 ? 'Up next' : 'Waiting…');
return `
<div class="pw_suggestion_card pw_streaming_slot ${stateClass}" data-slot="${i}" data-streaming="1">
<div class="pw_card_header">
<span class="pw_card_emoji pw_streaming_icon">${isFirst ? '<i class="fa-solid fa-pen-nib"></i>' : `<span class="pw_slot_num">${i + 1}</span>`}</span>
<span class="pw_card_title">${title}</span>
</div>
<div class="pw_card_description pw_streaming_placeholder">${desc}</div>
<div class="pw_card_actions" style="visibility: hidden;"></div>
</div>`;
}).join('');
body.html(`
<div class="pw_status">
<i class="fa-solid fa-circle-notch pw_spin"></i>
<span>Streaming ${catName} suggestions...</span>
<div class="pw_status_actions">
<button class="pw_status_btn cancel pw_throb" id="pw_cancel_gen">
<i class="fa-solid fa-xmark"></i> Cancel
</button>
</div>
</div>
<div class="pw_suggestions_grid" id="pw_streaming_grid" data-streaming-slots="${count}">
${cardsHtml}
</div>
`);
jQuery('#pw_cancel_gen').off('click').on('click', function (e) {
e.stopPropagation();
e.preventDefault();
if (abortController) abortController.abort();
});
}
function updateStreamingCardContent(partialText, outputContainer) {
const body = outputContainer || jQuery('#pw_modal_body');
const grid = body.find('#pw_streaming_grid');
if (!grid.length) return;
const card = grid.find('.pw_streaming_active');
if (!card.length) return;
const { DOMPurify } = SillyTavern.libs;
const safe = DOMPurify.sanitize(partialText || '', { ALLOWED_TAGS: [] });
const firstLine = safe.split('\n')[0].trim() || '…';
card.find('.pw_card_title').text(firstLine.substring(0, 100));
card.find('.pw_card_description').text(safe).removeClass('pw_streaming_placeholder');
}
function appendStreamingCardAsComplete(suggestion, outputContainer, suggestionsArray) {
const body = outputContainer || jQuery('#pw_modal_body');
const grid = body.find('#pw_streaming_grid');
if (!grid.length) return;
const card = grid.find('.pw_streaming_active');
const { DOMPurify } = SillyTavern.libs;
const safeTitle = DOMPurify.sanitize(suggestion.title, { ALLOWED_TAGS: [] });
const safeDesc = DOMPurify.sanitize(suggestion.description, { ALLOWED_TAGS: [] });
const safeEmoji = suggestion.emoji || '✨';
const index = suggestionsArray.length;
suggestionsArray.push(suggestion);
if (card.length) {
card.removeClass('pw_streaming_slot pw_streaming_active pw_streaming_waiting').removeAttr('data-streaming data-slot');
card.find('.pw_card_emoji').text(safeEmoji);
card.find('.pw_card_title').text(safeTitle);
card.find('.pw_card_description').text(safeDesc);
card.find('.pw_card_actions').attr('style', '').html(`
<button class="pw_card_action_btn" data-action="copy" title="Copy to clipboard"><i class="fa-solid fa-copy"></i> Copy</button>
<button class="pw_card_action_btn" data-action="insert" title="Insert into input field"><i class="fa-solid fa-plus"></i> Insert</button>
<button class="pw_card_action_btn primary" data-action="send" title="Insert and send"><i class="fa-solid fa-paper-plane"></i> Send</button>
`);
card.attr('data-index', index);
const nextActive = grid.find('.pw_streaming_waiting').first();
if (nextActive.length) {
nextActive.removeClass('pw_streaming_waiting').addClass('pw_streaming_active');
nextActive.find('.pw_card_emoji').html('<i class="fa-solid fa-pen-nib"></i>');
nextActive.find('.pw_card_title').text('Streaming…');
nextActive.find('.pw_card_description').text('').removeClass('pw_streaming_placeholder');
}
}
grid.find('.pw_suggestion_card[data-index]').off('click.pw_stream').on('click.pw_stream', function (e) {
const idx = parseInt(jQuery(this).data('index'), 10);
const s = suggestionsArray[idx];
if (!s) return;
const action = jQuery(e.target).closest('[data-action]').data('action');
if (action === 'copy') copyToClipboard(s.description);
else if (action === 'insert') insertSuggestion(s);
else if (action === 'send') sendSuggestion(s);
});
}
function removeStreamingPlaceholderCard(outputContainer) {
const body = outputContainer || jQuery('#pw_modal_body');
body.find('.pw_streaming_slot').remove();
}
/**
* Consume a ReadableStream (e.g. from Connection Profile / Gemini); detect SSE or NDJSON and push text into addContent.
* @param {ReadableStream} stream - response.body or any getReader()-able stream
* @param {function(string): void} addContent - called with extracted text (may be called many times)
* @param {function(string, boolean): void} onChunk - optional (chunkText, isFirst) for logging
*/
async function consumeGenericStream(stream, addContent, onChunk) {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = '';
let first = true;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('data:')) {
const dataStr = trimmed.slice(5).trim();
if (dataStr === '[DONE]') continue;
try {
const obj = JSON.parse(dataStr);
const delta = obj.choices?.[0]?.delta?.content;
if (delta) {
if (onChunk && first) { onChunk(delta, true); first = false; }
addContent(delta);
}
} catch (_) { }
} else {
try {
const obj = JSON.parse(trimmed);
if (obj.response != null) {
if (onChunk && first) { onChunk(obj.response, true); first = false; }
addContent(obj.response);
}
} catch (_) { }
}
}
}
if (buffer.trim()) {
if (buffer.trim().startsWith('data:')) {
try {
const obj = JSON.parse(buffer.trim().slice(5).trim());
const delta = obj.choices?.[0]?.delta?.content;
if (delta) addContent(delta);
} catch (_) { }
} else {
try {
const obj = JSON.parse(buffer.trim());
if (obj.response != null) addContent(obj.response);
} catch (_) { addContent(buffer); }
}
}
}
async function runStreamingGeneration(opts) {
const { source, categoryPrompt, userPrompt, calculatedMaxTokens, category, outputContainer, abortController } = opts;
const body = outputContainer || jQuery('#pw_modal_body');
const suggestionsArray = [];
let contentBuffer = '';
let processedBlockCount = 0;
const maxSuggestions = settings.suggestions_count;
showStreamingState(outputContainer, category);
if (source === 'profile' || source === 'default') {
console.log(STREAM_LOG, source === 'profile' ? 'Connection Profile' : 'Main API', 'streaming attempt started. Copy these logs to debug.');
}
const processBuffer = () => {
const { completeBlocks, partial } = splitStreamBuffer(contentBuffer);
const newBlocks = completeBlocks.slice(processedBlockCount);
processedBlockCount = completeBlocks.length;
for (const block of newBlocks) {
if (suggestionsArray.length >= maxSuggestions) break;
const suggestion = parseOneBlock(block);
if (suggestion) {
appendStreamingCardAsComplete(suggestion, outputContainer, suggestionsArray);
}
}
updateStreamingCardContent(partial, outputContainer);
};
try {
if (source === 'profile') {
const stContext = SillyTavern.getContext();
const cm = stContext?.extensionSettings?.connectionManager;
const profile = cm?.profiles?.find(p => p.name === settings.preset);
if (!profile) throw new Error(`Profile '${settings.preset}' not found`);
if (!stContext.ConnectionManagerRequestService) throw new Error('ConnectionManagerRequestService not available');
const messages = [
{ role: 'system', content: categoryPrompt },
{ role: 'user', content: userPrompt }
];
const requestOpts = {
stream: true,
signal: abortController.signal,
extractData: true,
includePreset: true,
includeInstruct: true
};
console.log(STREAM_LOG, 'Connection Profile: attempting stream=true', { profileName: profile.name, profileId: profile.id, maxTokens: calculatedMaxTokens, requestOptsKeys: Object.keys(requestOpts) });
let rawResponse = stContext.ConnectionManagerRequestService.sendRequest(profile.id, messages, calculatedMaxTokens, requestOpts);
let response = rawResponse && typeof rawResponse.then === 'function' ? await rawResponse : rawResponse;
if (typeof response === 'function') {
console.log(STREAM_LOG, 'Connection Profile: response is a function (generator?), calling it to get iterator');
response = response();
if (response && typeof response.then === 'function') response = await response;
}
const isAsyncIterable = response != null && (typeof response[Symbol.asyncIterator] === 'function' || typeof response.next === 'function');
console.log(STREAM_LOG, 'Connection Profile: response received', { type: typeof response, constructor: response?.constructor?.name, hasBody: !!response?.body, hasGetReader: typeof response?.body?.getReader === 'function', hasContent: !!response?.content, contentLength: typeof response?.content === 'string' ? response.content.length : 0, isAsyncIterable });
if (response?.content != null && typeof response.content === 'string') {
console.log(STREAM_LOG, 'Connection Profile: full content (non-streaming), using as single buffer. Length:', response.content.length);
contentBuffer = response.content;
processBuffer();
} else if (response && typeof response === 'string') {
console.log(STREAM_LOG, 'Connection Profile: response is string (non-streaming). Length:', response.length);
contentBuffer = response;
processBuffer();
} else if (isAsyncIterable) {
console.log(STREAM_LOG, 'Connection Profile: consuming response as async iterator (e.g. AsyncGenerator from Gemini)');
let firstChunk = true;
try {
for await (const chunk of response) {
if (abortController.signal.aborted) break;
let text = '';
if (typeof chunk === 'string') text = chunk;
else if (chunk && typeof chunk === 'object') {
text = chunk.choices?.[0]?.delta?.content ?? chunk.content ?? chunk.response ?? (typeof chunk.text === 'string' ? chunk.text : '');
}
if (text) {
if (firstChunk) {
console.log(STREAM_LOG, 'Connection Profile: first chunk sample (first 200 chars):', JSON.stringify(String(text).slice(0, 200)));
firstChunk = false;
}
if (contentBuffer.length === 0) {
contentBuffer = text;
} else if (text.startsWith(contentBuffer)) {
contentBuffer = text;
} else {
contentBuffer += text;
}
processBuffer();
}
}
console.log(STREAM_LOG, 'Connection Profile: async iterator finished. Total content length:', contentBuffer.length);
} catch (iterErr) {
console.log(STREAM_LOG, 'Connection Profile: async iterator error:', iterErr?.message || String(iterErr));
throw iterErr;
}
} else if (response?.body && typeof response.body.getReader === 'function') {
console.log(STREAM_LOG, 'Connection Profile: consuming response.body as ReadableStream');
await consumeGenericStream(response.body, (text) => { contentBuffer += text; processBuffer(); }, (chunk, isFirst) => {
if (isFirst) console.log(STREAM_LOG, 'Connection Profile: first chunk sample (first 200 chars):', JSON.stringify(String(chunk).slice(0, 200)));
});
console.log(STREAM_LOG, 'Connection Profile: stream finished. Total content length:', contentBuffer.length);
} else if (response && typeof response.getReader === 'function') {
console.log(STREAM_LOG, 'Connection Profile: consuming response as ReadableStream');
await consumeGenericStream(response, (text) => { contentBuffer += text; processBuffer(); }, (chunk, isFirst) => {
if (isFirst) console.log(STREAM_LOG, 'Connection Profile: first chunk sample (first 200 chars):', JSON.stringify(String(chunk).slice(0, 200)));
});
console.log(STREAM_LOG, 'Connection Profile: stream finished. Total content length:', contentBuffer.length);
} else {
console.log(STREAM_LOG, 'Connection Profile: unknown response shape, attempting to extract text. Keys:', response ? Object.keys(response) : []);
const text = response?.choices?.[0]?.message?.content ?? (typeof response === 'string' ? response : '');
if (text) contentBuffer = text; processBuffer();
}
} else if (source === 'default') {
const stContext = SillyTavern.getContext();
const { generateRaw } = stContext;
if (!generateRaw) throw new Error('generateRaw not available in context');
console.log(STREAM_LOG, 'Main API: attempting streaming: true');
let rawResult = generateRaw({ systemPrompt: categoryPrompt, prompt: userPrompt, streaming: true });
const result = rawResult && typeof rawResult.then === 'function' ? await rawResult : rawResult;
console.log(STREAM_LOG, 'Main API: result received', { type: typeof result, constructor: result?.constructor?.name, hasGetReader: typeof result?.getReader === 'function', hasBody: !!result?.body, stringLength: typeof result === 'string' ? result.length : 0 });
if (typeof result === 'string') {
console.log(STREAM_LOG, 'Main API: full string (non-streaming). Length:', result.length);
contentBuffer = result;
processBuffer();
} else if (result?.body && typeof result.body.getReader === 'function') {
console.log(STREAM_LOG, 'Main API: consuming result.body as ReadableStream');
await consumeGenericStream(result.body, (text) => { contentBuffer += text; processBuffer(); }, (chunk, isFirst) => {
if (isFirst) console.log(STREAM_LOG, 'Main API: first chunk sample:', JSON.stringify(String(chunk).slice(0, 200)));
});
} else if (result && typeof result.getReader === 'function') {
console.log(STREAM_LOG, 'Main API: consuming result as ReadableStream');
await consumeGenericStream(result, (text) => { contentBuffer += text; processBuffer(); }, (chunk, isFirst) => {
if (isFirst) console.log(STREAM_LOG, 'Main API: first chunk sample:', JSON.stringify(String(chunk).slice(0, 200)));
});
} else {
console.log(STREAM_LOG, 'Main API: unknown result shape. Using as non-streaming.');
contentBuffer = (result && typeof result === 'object' && result.content) ? result.content : String(result ?? '');
processBuffer();
}
} else if (source === 'ollama') {
const baseUrl = (settings.ollama_url || 'http://localhost:11434').replace(/\/$/, '');
if (!settings.ollama_model) throw new Error('No Ollama model selected');
log(`Streaming with Ollama: ${settings.ollama_model}`);
const response = await fetch(`${baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: settings.ollama_model,
system: categoryPrompt,
prompt: userPrompt,
stream: true,
options: { num_ctx: 8192, num_predict: calculatedMaxTokens }
}),
signal: abortController.signal
});
if (!response.ok) throw new Error(`Ollama API error: ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let lineBuffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
lineBuffer += decoder.decode(value, { stream: true });
const lines = lineBuffer.split('\n');
lineBuffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed);
if (obj.response) contentBuffer += obj.response;
} catch (_) { /* skip invalid JSON */ }
}
processBuffer();
}
if (lineBuffer.trim()) {
try {
const obj = JSON.parse(lineBuffer.trim());
if (obj.response) contentBuffer += obj.response;
} catch (_) { }
}
} else if (source === 'openai') {
const baseUrl = (settings.openai_url || 'http://localhost:1234/v1').replace(/\/$/, '');
log(`Streaming with OpenAI-compatible: ${baseUrl}`);
const headers = { 'Content-Type': 'application/json' };
if (settings.openai_key) headers['Authorization'] = `Bearer ${settings.openai_key}`;
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({
model: settings.openai_model || 'local-model',
messages: [
{ role: 'system', content: categoryPrompt },
{ role: 'user', content: userPrompt }
],
temperature: 0.8,
max_tokens: calculatedMaxTokens,
stream: true
}),
signal: abortController.signal
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let sseBuffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
sseBuffer += decoder.decode(value, { stream: true });
const eventEnd = sseBuffer.indexOf('\n\n');
if (eventEnd === -1) continue;
const events = sseBuffer.split(/\n\n+/);
sseBuffer = events.pop() || '';
for (const event of events) {
const lines = event.split('\n');
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const dataStr = line.slice(5).trim();
if (dataStr === '[DONE]') continue;
try {
const obj = JSON.parse(dataStr);
const delta = obj.choices?.[0]?.delta?.content;
if (delta) contentBuffer += delta;
} catch (_) { }
}
}
processBuffer();
}
}
} finally {
// Process any remaining complete blocks and final partial
const { completeBlocks, partial } = splitStreamBuffer(contentBuffer);
const remainingBlocks = completeBlocks.slice(processedBlockCount);
for (const block of remainingBlocks) {
if (suggestionsArray.length >= maxSuggestions) break;
const suggestion = parseOneBlock(block);
if (suggestion) appendStreamingCardAsComplete(suggestion, outputContainer, suggestionsArray);
}
if (suggestionsArray.length < maxSuggestions && partial.trim().length >= 10) {
const tailSplit = splitStreamBuffer(partial);
for (const block of tailSplit.completeBlocks) {
if (suggestionsArray.length >= maxSuggestions) break;
const suggestion = parseOneBlock(block);
if (suggestion) appendStreamingCardAsComplete(suggestion, outputContainer, suggestionsArray);
}
if (suggestionsArray.length < maxSuggestions && tailSplit.partial.trim().length >= 10) {
const suggestion = parseOneBlock(tailSplit.partial);
if (suggestion) appendStreamingCardAsComplete(suggestion, outputContainer, suggestionsArray);
}
}
removeStreamingPlaceholderCard(outputContainer);
}
if (abortController.signal.aborted) throw new DOMException('Aborted', 'AbortError');
if (suggestionsArray.length > 0) {
if (category !== 'director') cachedSuggestions[category] = suggestionsArray;
body.find('.pw_status').remove();
} else {
body.find('.pw_status').remove();
showEmptyState('No suggestions could be generated. Try again.', outputContainer);
}
}
// ============================================================
// UI - ACTION BAR
// ============================================================
function createActionBar() {
log('Creating action bar (refactored)...');
// Remove any existing bar
jQuery('.pw_action_bar').remove();
if (!settings.enabled) {
log('Extension disabled, not creating bar');
return;
}
const allCategories = getAllCategories();
// 4. Surprise Dropdown (built first so it can be inserted right after Director)
let surpriseItems = '';
// Main categories
for (const [key, cat] of Object.entries(MAIN_CATEGORIES)) {
if (cat.nsfw && !settings.show_explicit) continue;
const sIcon = allCategories[key]?.icon || cat.icon;
surpriseItems += `
<button class="pw_dropdown_item pw_surprise_item" data-surprise-category="${key}">
<i class="fa-solid ${sIcon}"></i>
<span>${cat.name}</span>
</button>`;
}
// Genre categories
const sortedGenresSurprise = Object.entries(GENRE_CATEGORIES).sort((a, b) => a[1].name.localeCompare(b[1].name));
for (const [key, cat] of sortedGenresSurprise) {
if (cat.nsfw && !settings.show_explicit) continue;
const sIcon = allCategories[key]?.icon || cat.icon;
surpriseItems += `
<button class="pw_dropdown_item pw_surprise_item" data-surprise-category="${key}">
<i class="fa-solid ${sIcon}"></i>
<span>${cat.name}</span>
</button>`;
}
// Custom styles
if (settings.custom_styles?.length) {
for (const style of settings.custom_styles) {
surpriseItems += `
<button class="pw_dropdown_item pw_surprise_item" data-surprise-category="${style.id}">
<i class="fa-solid ${style.icon}"></i>
<span>${style.name}</span>
</button>`;
}
}
const surpriseCount = activeSurprises.length;
const surpriseIndicatorHtml = surpriseCount > 0
? `<span class="pw_surprise_active_dot" title="${surpriseCount} surprise${surpriseCount > 1 ? 's' : ''} armed"></span>`
: '';
const surpriseDropdownHtml = `
<div class="pw_dropdown_container pw_surprise_container">
<button class="pw_dropdown_btn pw_surprise_btn${surpriseCount > 0 ? ' pw_surprise_armed' : ''}" data-name="Surprise" title="Surprise Me">
<i class="fa-solid fa-wand-sparkles"></i>
${surpriseIndicatorHtml}
</button>
<div class="pw_dropdown_menu pw_surprise_menu">
<div class="pw_surprise_menu_header">
<i class="fa-solid fa-wand-sparkles"></i> Surprise Me
<span class="pw_setting_tooltip_icon" title="Surprise Me secretly injects an AI-generated suggestion into the chat context a set number of messages before it fires. Pick a style, and Pathweaver will quietly arm a hidden prompt. When the countdown hits, the suggestion appears naturally — like the story took an unexpected turn on its own.">?</span>
</div>
${settings.surprise_endless ? `<div class="pw_surprise_endless_badge">
<i class="fa-solid fa-infinity"></i> Endless Surprises active
</div>` : ''}
${surpriseCount > 0 ? `<div class="pw_surprise_active_info">
<span class="pw_surprise_active_info_label">
<i class="fa-solid fa-circle-check" style="color: var(--pw-success);"></i>
${surpriseCount} surprise${surpriseCount > 1 ? 's' : ''} armed
</span>
<button class="pw_surprise_clear_btn" id="pw_surprise_clear">Clear all</button>
</div>` : ''}
${surpriseItems}
</div>
</div>`;
// 1. Built-in Buttons — Director first, then Surprise Me, then main categories
let builtinButtonsHtml = '';
// Director Button (Special)
builtinButtonsHtml += `
<button class="pw_cat_btn pw_director_btn"
data-category="director"
data-name="Director"
title="Director: Take control of the story">
<i class="fa-solid fa-clapperboard"></i>
</button>`;
// Surprise Me dropdown — immediately after Director
builtinButtonsHtml += surpriseDropdownHtml;
// Main Categories (Context, Twist, Character, Explicit)
let categoryOptionsHtml = '<option value="director">Director Mode</option>';
for (const [key, cat] of Object.entries(MAIN_CATEGORIES)) {
if (cat.nsfw && !settings.show_explicit) continue;
const bIcon = allCategories[key]?.icon || cat.icon;
const btnHtml = `
<button class="pw_cat_btn"
data-category="${key}"
data-name="${cat.name}"
title="${cat.name}: ${cat.tooltip}">
<i class="fa-solid ${bIcon}"></i>
</button>`;
builtinButtonsHtml += btnHtml;
categoryOptionsHtml += `<option value="${key}">${cat.name}</option>`;
}
// 2. Custom Styles (Combined Dropdown)
let customDropdownHtml = '';
if (settings.custom_styles?.length) {
let customItems = '';
for (const style of settings.custom_styles) {
customItems += `
<button class="pw_dropdown_item" data-category="${style.id}">
<i class="fa-solid ${style.icon}"></i>
<span>${style.name}</span>
</button>`;
// Also add to the mobile/fallback select
categoryOptionsHtml += `<option value="${style.id}">${style.name}</option>`;
}
customDropdownHtml = `
<div class="pw_dropdown_container">
<button class="pw_dropdown_btn" data-name="Custom Styles" title="Custom Styles">
<i class="fa-solid fa-layer-group"></i>
</button>
<div class="pw_dropdown_menu">
${customItems}
</div>
</div>`;
}
// 3. Genre Dropdown (Visual)
// Sort genres alphabetically
const sortedGenres = Object.entries(GENRE_CATEGORIES).sort((a, b) => a[1].name.localeCompare(b[1].name));
let genreItems = '';
let hasVisibleGenres = false;
for (const [key, cat] of sortedGenres) {
if (cat.nsfw && !settings.show_explicit) continue;
const gIcon = allCategories[key]?.icon || cat.icon;
genreItems += `
<button class="pw_dropdown_item" data-category="${key}">
<i class="fa-solid ${gIcon}"></i>
<span>${cat.name}</span>
</button>`;
categoryOptionsHtml += `<option value="${key}">${cat.name}</option>`;
hasVisibleGenres = true;
}
const genreDropdownHtml = hasVisibleGenres ? `
<div class="pw_dropdown_container">
<button class="pw_dropdown_btn" data-name="Genres" title="Genres">
<i class="fa-solid fa-masks-theater"></i>
</button>
<div class="pw_dropdown_menu">
${genreItems}
</div>
</div>
` : '';
const minimized = settings.bar_minimized ? ' minimized' : '';
const arrowIcon = settings.bar_minimized ? 'fa-chevron-up' : 'fa-chevron-down';
const minimizeTitle = settings.bar_minimized ? 'Show Pathweaver' : 'Hide Pathweaver';
const fontClass = settings.bar_font_size !== 'default' ? ` pw_font_${settings.bar_font_size}` : '';
const heightClass = settings.bar_height !== 'default' ? ` pw_height_${settings.bar_height}` : '';
const titleFontClass = settings.bar_title_font === 'none' ? ' pw_bar_title_none' : (settings.bar_title_font !== 'default' ? ` pw_bar_title_font_${settings.bar_title_font}` : '');
const hideAnimatedBarClass = settings.hide_animated_bar ? ' pw_hide_animated_bar' : '';
const barHtml = `
<div class="pw_action_bar${minimized}${fontClass}${heightClass}${titleFontClass}${hideAnimatedBarClass}">
<span class="pw_bar_title">Pathweaver</span>
<div class="pw_category_buttons">
${builtinButtonsHtml}
${customDropdownHtml}
${genreDropdownHtml}
</div>
<select class="pw_category_dropdown" title="Select a suggestion style">
<option value="" disabled selected>Style...</option>
${categoryOptionsHtml}
</select>
<div class="pw_bar_right">
<span class="pw_hover_label" id="pw_hover_label"></span>
<button class="pw_icon_btn" id="pw_bar_settings" title="Pathweaver Settings">
<i class="fa-solid fa-gear"></i>
</button>
</div>
<button class="pw_minimize_btn" id="pw_minimize_bar" title="${minimizeTitle}">
<i class="fa-solid ${arrowIcon}"></i>
</button>
</div>`;
// Always insert above the send form (top position only)
const sendForm = jQuery('#send_form');
if (sendForm.length) {
sendForm.before(barHtml);
log('Bar inserted above #send_form');
} else {
// Fallback to form_sheld
const formSheld = jQuery('#form_sheld');
if (formSheld.length) {
formSheld.prepend(barHtml);
log('Bar inserted into #form_sheld');
} else {
// Last resort: append to body
jQuery('body').append(barHtml);
log('Bar inserted into body (fallback)');
}
}
actionBar = jQuery('.pw_action_bar');
if (actionBar.length) {
log('Action bar created successfully');
} else {
error('Failed to create action bar');
return;
}
// Setup responsive switching between buttons and dropdown
setupResponsiveBar();
// ------------------------------------------------------------
// EVENT HANDLERS
// ------------------------------------------------------------
const eventNs = '.pw_action_bar_events';
jQuery(document).off(eventNs);
// 1. Regular Buttons
jQuery(document).on(`click${eventNs}`, '.pw_cat_btn', function (e) {
const category = jQuery(this).data('category');
if (category === 'director') {
e.stopPropagation();
showDirectorModal();
return;
}
openSuggestionsModal(category);
});
// 2. Dropdown Toggles
jQuery(document).on('click.pw_action_bar_events touchend.pw_action_bar_events', '.pw_dropdown_btn', function (e) {
e.stopPropagation();
if (e.type === 'touchend') e.preventDefault();
const btn = jQuery(this);
const menu = btn.siblings('.pw_dropdown_menu');
const isActive = btn.hasClass('active');
// Close all others
jQuery('.pw_dropdown_menu').removeClass('show');
jQuery('.pw_dropdown_btn').removeClass('active');
// Toggle this one if it wasn't already active
if (!isActive) {
btn.addClass('active');
menu.addClass('show');
}
});
// Handle item clicks
jQuery(document).on('click.pw_action_bar_events touchend.pw_action_bar_events', '.pw_dropdown_item', function (e) {
e.stopPropagation();
if (e.type === 'touchend') e.preventDefault();
const category = jQuery(this).data('category');
// Close menus
jQuery('.pw_dropdown_menu').removeClass('show');
jQuery('.pw_dropdown_btn').removeClass('active');
if (category) {
openSuggestionsModal(category);
}
});
// 4. Close on Outside Click
jQuery(document).on(`click${eventNs}`, function (e) {
if (!jQuery(e.target).closest('.pw_dropdown_container').length) {
jQuery('.pw_dropdown_menu').removeClass('show');
jQuery('.pw_dropdown_btn').removeClass('active');
}
});
// 5. Fallback Select
jQuery(document).on(`change${eventNs}`, '.pw_category_dropdown', function () {
const category = this.value;
if (category) {
if (category === 'director') {
showDirectorModal();
} else {
openSuggestionsModal(category);
}
this.selectedIndex = 0; // Reset
}
});
// 7. Hover Labels (Delegated)
jQuery(document).on(`mouseenter${eventNs}`, '.pw_cat_btn, .pw_dropdown_btn', function () {
const name = jQuery(this).data('name');
if (name) {
jQuery('#pw_hover_label').text(name).addClass('visible');
}
}).on(`mouseleave${eventNs}`, '.pw_cat_btn, .pw_dropdown_btn', function () {
jQuery('#pw_hover_label').removeClass('visible');
});
// 6. Settings & Minimize
jQuery(document).on(`click${eventNs}`, '#pw_bar_settings', openSettingsModal);
jQuery(document).on(`click${eventNs} touchend${eventNs}`, '#pw_minimize_bar', function (e) {
// touchend: prevent the double-fire from the subsequent synthetic click
if (e.type === 'touchend') e.preventDefault();
settings.bar_minimized = !settings.bar_minimized;
saveSettings();
createActionBar();
});
// 8. Surprise items
jQuery(document).on('click.pw_action_bar_events touchend.pw_action_bar_events', '.pw_surprise_item', function (e) {
e.stopPropagation();
if (e.type === 'touchend') e.preventDefault();
const category = jQuery(this).data('surprise-category');
jQuery('.pw_dropdown_menu').removeClass('show');
jQuery('.pw_dropdown_btn').removeClass('active');
if (category) {
showSurpriseModal(category);
}
});
// 9. Clear surprise button
jQuery(document).on('click.pw_action_bar_events', '#pw_surprise_clear', function (e) {
e.stopPropagation();
clearAllSurprises();
renderSurpriseQueue();
createActionBar();
showToast('Surprises cleared!');
});
}
function setupResponsiveBar() {
const bar = document.querySelector('.pw_action_bar');
if (!bar) return;
const checkWidth = () => {
const buttons = bar.querySelector('.pw_category_buttons');
const dropdown = bar.querySelector('.pw_category_dropdown');
if (!buttons || !dropdown) return;
// Make buttons visible temporarily so we can measure naturally
const prevDisplay = buttons.style.display;
buttons.style.display = 'flex';
buttons.style.flexWrap = 'nowrap';
const barRight = bar.querySelector('.pw_bar_right');
const title = bar.querySelector('.pw_bar_title');
// Sum fixed-width elements; title is hidden on very narrow screens
const usedWidth =
(title && title.offsetWidth > 0 ? title.offsetWidth + 8 : 0) +
(barRight ? barRight.offsetWidth + 8 : 52) +
32; // minimize btn + gaps
const availableForButtons = bar.offsetWidth - usedWidth;
// Only collapse to dropdown if even the smallest useful slice of buttons can't show
// (< 2 buttons worth ~80px). On mobile the buttons container is scrollable so
// all buttons remain accessible even on narrow screens.
if (availableForButtons < 80) {
buttons.style.display = 'none';
dropdown.style.display = 'block';
} else {
buttons.style.display = 'flex';
dropdown.style.display = 'none';
}
};
// Check immediately and on resize
checkWidth();
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(checkWidth);
observer.observe(bar);
} else {
window.addEventListener('resize', checkWidth);
}
}
function updateActionBarVisibility() {
if (actionBar && actionBar.length) {
settings.enabled ? actionBar.show() : actionBar.hide();
}
}
// ============================================================
// UI - SUGGESTIONS MODAL
// ============================================================
function showDirectorModal() {
// Remove existing modal to ensure fresh state and logic
if (jQuery('#pw_director_modal').length) {
jQuery('#pw_director_modal').remove();
}
const modalHtml = `
<div class="pw_modal_overlay" id="pw_director_modal">
<div class="pw_modal" style="max-width: 600px;">
<div class="pw_modal_header">
<h3 class="pw_modal_title">
<i class="fa-solid fa-clapperboard" style="color: var(--pw-director-color);"></i>
Director Mode
</h3>
<button class="pw_modal_close" id="pw_close_director">&times;</button>
</div>
<!-- Overflow hidden for slide/flip; Flex column for layout -->
<div class="pw_modal_body" style="overflow: hidden; display: flex; flex-direction: column;">
<div class="pw_director_container">
<!-- VIEW 1: INPUTS -->
<div class="pw_director_view visible" id="pw_director_inputs_view">
<div class="pw_director_mode_switch">
<div class="pw_mode_option ${directorMode === 'single_scene' ? 'active' : ''}" data-mode="single_scene">
<div class="pw_mode_title"><i class="fa-solid fa-film"></i> Single Scene</div>
<div class="pw_mode_desc">Combine inputs into one rich scene</div>
</div>
<div class="pw_mode_option ${directorMode === 'story_beats' ? 'active' : ''}" data-mode="story_beats">
<div class="pw_mode_title"><i class="fa-solid fa-list-check"></i> Story Beats</div>
<div class="pw_mode_desc">One suggestion per input beat</div>
</div>
</div>
<div class="pw_director_inputs" id="pw_director_inputs" style="max-height: 40vh; overflow-y: auto; padding-right: 5px;">
<!-- Inputs injected here -->
</div>
<div class="pw_director_actions" style="justify-content: space-between; gap: 10px;">
<button class="pw_add_direction_btn" id="pw_reset_dir_btn" style="width: auto; flex: 1; border-color: var(--pw-glass-border); background: transparent;">
<i class="fa-solid fa-rotate-left"></i> Reset
</button>
<button class="pw_add_direction_btn" id="pw_add_dir_btn" style="flex: 2;">
<i class="fa-solid fa-plus"></i> Add Another Direction
</button>
</div>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<button class="pw_header_btn primary pw_director_generate_btn" id="pw_director_generate" style="margin-top:0;">
<i class="fa-solid fa-wand-magic-sparkles"></i> Generate Suggestions
</button>
<button class="pw_header_btn" id="pw_show_results_btn" style="display:none; flex: 1; justify-content: center; align-items: center; gap: 8px;">
Show Suggestions <i class="fa-solid fa-arrow-right"></i>
</button>
</div>
</div>
<!-- VIEW 2: RESULTS -->
<div class="pw_director_view hidden" id="pw_director_results_view">
<div class="pw_results_header">
<button class="pw_back_btn" id="pw_director_back">
<i class="fa-solid fa-arrow-left"></i> Back to Director Mode
</button>
<!-- "Suggestions" text removed as requested -->
</div>
<div id="pw_director_results_content">
<!-- Results injected here -->
</div>
</div>
</div>
</div>
</div>
</div>`;
jQuery('body').append(modalHtml);
const modal = jQuery('#pw_director_modal');
const container = jQuery('#pw_director_inputs');
const inputsView = jQuery('#pw_director_inputs_view');
const resultsView = jQuery('#pw_director_results_view');
const addBtn = jQuery('#pw_add_dir_btn');
const showResultsBtn = jQuery('#pw_show_results_btn');
let suggestionsGenerated = false;
const placeholdersRandom = [
"e.g. A masked stranger bursts through the tavern doors...",
"e.g. The ancient amulet begins to glow pulsingly...",
"e.g. A sudden thunderstorm forces them to seek shelter cave...",
"e.g. He reveals a hidden dagger from his sleeve...",
"e.g. The spaceship's alarm blares 'CRITICAL FAILURE'...",
"e.g. She whispers a secret that changes everything..."
];
const placeholdersContinuous = [
"e.g. The detective enters the dimly lit office...",
"e.g. She notices a folder left on the desk...",
"e.g. She opens it to reveal the missing evidence...",
"e.g. A sudden noise from the hallway startles her...",
"e.g. She quickly hides the folder under her coat...",
"e.g. The door creaks open slowly..."
];
// Helper to add input
const addInput = (focus = false) => {
const count = container.children().length;
if (count >= 6) return;
let ph = '';
if (directorMode === 'single_scene') {
ph = placeholdersContinuous[count % placeholdersContinuous.length];
} else {
ph = placeholdersRandom[count % placeholdersRandom.length];
}
const html = `
<div class="pw_director_input_group">
<div class="pw_director_input_label">${count + 1}</div>
<input type="text" class="pw_director_input" placeholder="${ph}" maxlength="200">
<button class="pw_director_remove_btn" title="Remove">
<i class="fa-solid fa-times"></i>
</button>
</div>`;
const el = jQuery(html);
container.append(el);
el.find('.pw_director_remove_btn').on('click', function () {
el.remove();
renumberInputs();
});
if (focus) el.find('input').focus();
checkLimit();
};
const renumberInputs = () => {
container.find('.pw_director_input_label').each((i, el) => {
jQuery(el).text(i + 1);
});
checkLimit();
};
const checkLimit = () => {
const count = container.children().length;
if (count >= 6) {
addBtn.hide();
} else {
addBtn.show();
}
};
// Initialize with 3 inputs
addInput();
addInput();
addInput();
// Events
addBtn.on('click', () => addInput(true));
jQuery('#pw_reset_dir_btn').on('click', () => {
container.empty();
addInput();
addInput();
addInput();
suggestionsGenerated = false;
showResultsBtn.hide();
});
jQuery('#pw_close_director').on('click', () => modal.removeClass('active'));
modal.on('click', (e) => {
if (e.target === modal[0]) modal.removeClass('active');
});
// Mode switching logic
jQuery('.pw_mode_option').on('click', function () {
jQuery('.pw_mode_option').removeClass('active');
jQuery(this).addClass('active');
directorMode = jQuery(this).data('mode');
// Switch placeholders immediately if Inputs view is visible
if (inputsView.hasClass('visible')) {
// We need to re-render inputs to update placeholders
// But we don't want to lose user text.
// For now, just clearing and resetting is simplest to show new examples,
// BUT preserving text is better.
// The user request says "the examples inside should reflect...", implies placeholders.
// We'll just update placeholders of empty inputs.
container.find('input').each(function (i) {
if (!jQuery(this).val()) {
let newPh = '';
if (directorMode === 'single_scene') {
newPh = placeholdersContinuous[i % placeholdersContinuous.length];
} else {
newPh = placeholdersRandom[i % placeholdersRandom.length];
}
jQuery(this).attr('placeholder', newPh);
}
});
}
});
// FLIP LOGIC
const showResults = () => {
inputsView.removeClass('visible').addClass('hidden');
resultsView.removeClass('hidden').addClass('visible');
};
const showInputs = () => {
resultsView.removeClass('visible').addClass('hidden');
inputsView.removeClass('hidden').addClass('visible');
if (suggestionsGenerated) showResultsBtn.css('display', 'inline-flex');
};
jQuery('#pw_director_back').on('click', showInputs);
showResultsBtn.on('click', showResults);
jQuery('#pw_director_generate').on('click', () => {
const directions = [];
container.find('input').each(function () {
const val = jQuery(this).val().trim();
if (val) directions.push(val);
});
if (directions.length === 0) {
alert('Please enter at least one direction.');
return;
}
// Flip to results
showResults();
suggestionsGenerated = true;
// Trigger generation rendered into the results content container
generateSuggestions('director', true, directions, directorMode, jQuery('#pw_director_results_content'));
});
// Show
setTimeout(() => modal.addClass('active'), 10);
}
function createSuggestionsModal() {
if (jQuery('#pw_suggestions_modal').length) return;
const modalHtml = `
<div class="pw_modal_overlay" id="pw_suggestions_modal">
<div class="pw_modal">
<div class="pw_modal_header">
<h3 class="pw_modal_title">
<i class="fa-solid fa-compass"></i>
<span id="pw_modal_title_text">Story Directions</span>
</h3>
<div class="pw_modal_actions">
<button class="pw_header_btn" id="pw_refresh_btn" title="Generate new suggestions">
<i class="fa-solid fa-rotate"></i> Refresh
</button>
<button class="pw_modal_close" id="pw_close_suggestions">&times;</button>
</div>
</div>
<div class="pw_modal_body" id="pw_modal_body"></div>
</div>
</div>`;
jQuery('body').append(modalHtml);
suggestionsModal = jQuery('#pw_suggestions_modal');
jQuery('#pw_close_suggestions').on('click', closeSuggestionsModal);
jQuery('#pw_refresh_btn').on('click', () => generateSuggestions(currentCategory, true));
suggestionsModal.on('click', (e) => {
if (e.target === suggestionsModal[0]) closeSuggestionsModal();
});
jQuery(document).on('keydown.pathweaver_suggestions', (e) => {
if (e.key === 'Escape' && suggestionsModal.hasClass('active')) closeSuggestionsModal();
});
}
function openSuggestionsModal(category) {
createSuggestionsModal();
currentCategory = category;
const allCategories = getAllCategories();
let catInfo = allCategories[category];
if (category === 'director') {
catInfo = { name: 'Director Instructions', icon: 'fa-clapperboard' };
}
jQuery('#pw_modal_title_text').text(catInfo?.name || 'Story Directions');
jQuery('#pw_suggestions_modal .pw_modal_title i')
.removeClass()
.addClass(`fa-solid ${catInfo?.icon || 'fa-compass'}`);
suggestionsModal.addClass('active');
generateSuggestions(category);
}
function closeSuggestionsModal() {
if (abortController) abortController.abort();
if (suggestionsModal) suggestionsModal.removeClass('active');
isGenerating = false;
}
function showLoadingState(category, outputContainer = null, customMessage = null) {
const body = outputContainer || jQuery('#pw_modal_body');
const allCategories = getAllCategories();
const catName = allCategories[category]?.name || category;
const msg = customMessage || `Generating ${catName} suggestions...`;
let skeletons = '';
for (let i = 0; i < Math.min(6, settings.suggestions_count); i++) {
skeletons += `
<div class="pw_skeleton_card">
<div class="pw_skeleton_emoji"></div>
<div class="pw_skeleton_title"></div>
<div class="pw_skeleton_line"></div>
<div class="pw_skeleton_line"></div>
</div>`;
}
body.html(`
<div class="pw_status">
<i class="fa-solid fa-circle-notch pw_spin"></i>
<span>${msg}</span>
<div class="pw_status_actions">
<button class="pw_status_btn cancel pw_throb" id="pw_cancel_gen">
<i class="fa-solid fa-xmark"></i> Cancel
</button>
</div>
</div>
<div class="pw_suggestions_grid">${skeletons}</div>
`);
jQuery('#pw_cancel_gen').off('click').on('click', function (e) {
e.stopPropagation();
e.preventDefault();
if (abortController) abortController.abort();
});
}
function showEmptyState(message = 'No suggestions available', outputContainer = null) {
const body = outputContainer || jQuery('#pw_modal_body');
body.html(`
<div class="pw_empty_state">
<i class="fa-solid fa-compass"></i>
<p>${message}</p>
</div>
`);
}
function showErrorState(message, outputContainer = null) {
const body = outputContainer || jQuery('#pw_modal_body');
body.html(`
<div class="pw_empty_state">
<i class="fa-solid fa-circle-exclamation" style="color: var(--pw-danger);"></i>
<p>${message}</p>
</div>
`);
}
function displaySuggestions(suggestions, category, outputContainer = null) {
const body = outputContainer || jQuery('#pw_modal_body');
if (!suggestions || suggestions.length === 0) {
showEmptyState('No suggestions could be generated. Try again.', outputContainer);
return;
}
const { DOMPurify } = SillyTavern.libs;
let cardsHtml = '<div class="pw_suggestions_grid">';
suggestions.forEach((suggestion, index) => {
const safeTitle = DOMPurify.sanitize(suggestion.title, { ALLOWED_TAGS: [] });
const safeDesc = DOMPurify.sanitize(suggestion.description, { ALLOWED_TAGS: [] });
const safeEmoji = suggestion.emoji || '✨';
cardsHtml += `
<div class="pw_suggestion_card" data-index="${index}">
<div class="pw_card_header">
<span class="pw_card_emoji">${safeEmoji}</span>
<span class="pw_card_title">${safeTitle}</span>
</div>
<div class="pw_card_description">${safeDesc}</div>
<div class="pw_card_actions">
<button class="pw_card_action_btn" data-action="copy" title="Copy to clipboard">
<i class="fa-solid fa-copy"></i> Copy
</button>
<button class="pw_card_action_btn" data-action="insert" title="Insert into input field">
<i class="fa-solid fa-plus"></i> Insert
</button>
<button class="pw_card_action_btn primary" data-action="send" title="Insert and send">
<i class="fa-solid fa-paper-plane"></i> Send
</button>
</div>
</div>`;
});
cardsHtml += '</div>';
body.html(cardsHtml);
jQuery('.pw_suggestion_card').on('click', function (e) {
const index = jQuery(this).data('index');
const suggestion = suggestions[index];
const action = jQuery(e.target).closest('[data-action]').data('action');
if (action === 'copy') {
copyToClipboard(suggestion.description);
} else if (action === 'insert') {
insertSuggestion(suggestion);
} else if (action === 'send') {
sendSuggestion(suggestion);
}
});
}
function getFormattedSuggestion(text) {
if (!settings.insert_type_enabled) return text;
if (settings.insert_type_ooc) return `[OOC: ${text}]`;
if (settings.insert_type_director) return `[Director: ${text}]`;
return text;
}
function copyToClipboard(text) {
const formatted = getFormattedSuggestion(text);
navigator.clipboard.writeText(formatted).then(() => showToast('Copied to clipboard!'));
}
function insertSuggestion(suggestion) {
const textarea = jQuery('#send_textarea');
const text = getFormattedSuggestion(suggestion.description);
const current = textarea.val();
if (settings.insert_mode) {
// Append Mode
textarea.val(current + (current ? '\n' : '') + text);
} else {
// Overwrite Mode (Default)
textarea.val(text);
}
// Trigger input event and resize the textarea
textarea.trigger('input');
// Force textarea to resize by triggering native events
const textareaEl = textarea[0];
if (textareaEl) {
textareaEl.style.height = 'auto';
textareaEl.style.height = textareaEl.scrollHeight + 'px';
textareaEl.dispatchEvent(new Event('input', { bubbles: true }));
}
closeSuggestionsModal();
jQuery('#pw_director_modal').removeClass('active');
showToast('Suggestion inserted!');
}
function sendSuggestion(suggestion) {
const textarea = jQuery('#send_textarea');
const text = getFormattedSuggestion(suggestion.description);
textarea.val(text);
textarea.trigger('input');
closeSuggestionsModal();
jQuery('#pw_director_modal').removeClass('active');
// Click the send button after a short delay
setTimeout(() => {
const sendBtn = jQuery('#send_but');
if (sendBtn.length) {
sendBtn.trigger('click');
showToast('Suggestion sent!');
}
}, 100);
}
function showToast(message) {
if (typeof toastr !== 'undefined') {
toastr.success(message, 'Pathweaver');
} else {
console.log('[Pathweaver-Toast]', message);
}
}
// ============================================================
// UI - SETTINGS MODAL
// ============================================================
function createSettingsModal() {
if (jQuery('#pw_settings_modal').length) {
jQuery('#pw_settings_modal').remove();
}
const profiles = getConnectionProfiles();
let profileOptions = '<option value="">-- Select Profile --</option>';
profiles.forEach(p => {
const selected = settings.preset === p.name ? ' selected' : '';
const safeName = escapeHtmlAttr(p.name);
profileOptions += `<option value="${safeName}"${selected}>${safeName}</option>`;
});
const modalHtml = `
<div class="pw_modal_overlay" id="pw_settings_modal">
<div class="pw_modal pw_settings_modal">
<div class="pw_modal_header">
<h3 class="pw_modal_title">
<i class="fa-solid fa-gear"></i>
Pathweaver Settings
</h3>
<button class="pw_modal_close" id="pw_close_settings">&times;</button>
</div>
<div class="pw_modal_body">
<div class="pw_settings_content">
<div class="pw_settings_section">
<h4 class="pw_settings_section_title">
<i class="fa-solid fa-sliders"></i> General
</h4>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-power-off"></i> Enable Pathweaver</span>
<div class="pw_toggle ${settings.enabled ? 'active' : ''}" data-setting="enabled"></div>
</div>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-eye-slash"></i> Hide Animated Bar</span>
<div class="pw_toggle ${settings.hide_animated_bar ? 'active' : ''}" data-setting="hide_animated_bar"></div>
</div>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-plus-circle"></i> Insert Mode (Append)</span>
<div class="pw_toggle ${settings.insert_mode ? 'active' : ''}" data-setting="insert_mode"></div>
</div>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-fire pw_nsfw_icon"></i> Show Explicit Category (NSFW)</span>
<div class="pw_toggle ${settings.show_explicit ? 'active' : ''}" data-setting="show_explicit"></div>
</div>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-code-branch"></i> Insert Type</span>
<div class="pw_toggle ${settings.insert_type_enabled ? 'active' : ''}" data-setting="insert_type_enabled"></div>
</div>
<div id="pw_sm_insert_type_options" style="${settings.insert_type_enabled ? 'display:flex' : 'display:none'}; flex-direction: column; gap: 8px; padding-left: 20px; border-left: 2px solid var(--SmartThemeBorderColor); margin-bottom: 10px;">
<div class="pw_setting_row">
<span class="pw_setting_label" style="font-size: 0.9em;">[OOC: ]</span>
<div class="pw_toggle ${settings.insert_type_ooc ? 'active' : ''}" data-setting="insert_type_ooc"></div>
</div>
<div class="pw_setting_row">
<span class="pw_setting_label" style="font-size: 0.9em;">[Director: ]</span>
<div class="pw_toggle ${settings.insert_type_director ? 'active' : ''}" data-setting="insert_type_director"></div>
</div>
</div>
</div>
<div class="pw_settings_section">
<h4 class="pw_settings_section_title">
<i class="fa-solid fa-wand-magic-sparkles"></i> Generation
</h4>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-list-ol"></i> Suggestions count</span>
<div class="pw_setting_control">
<select id="pw_sm_suggestions" class="pw_select text_pole">
<option value="2" ${settings.suggestions_count == 2 ? 'selected' : ''}>2</option>
<option value="4" ${settings.suggestions_count == 4 ? 'selected' : ''}>4</option>
<option value="6" ${settings.suggestions_count == 6 ? 'selected' : ''}>6</option>
</select>
</div>
</div>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-layer-group"></i> Context depth</span>
<div class="pw_setting_control">
<select id="pw_sm_context" class="pw_select text_pole">
<option value="2" ${settings.context_depth == 2 ? 'selected' : ''}>2 messages</option>
<option value="4" ${settings.context_depth == 4 ? 'selected' : ''}>4 messages</option>
<option value="6" ${settings.context_depth == 6 ? 'selected' : ''}>6 messages</option>
<option value="8" ${settings.context_depth == 8 ? 'selected' : ''}>8 messages</option>
<option value="10" ${settings.context_depth == 10 ? 'selected' : ''}>10 messages</option>
</select>
</div>
</div>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-text-width"></i> Suggestion length</span>
<div class="pw_setting_control">
<select id="pw_sm_suggestion_length" class="pw_select text_pole">
<option value="short" ${settings.suggestion_length === 'short' ? 'selected' : ''}>Short (2-3 sentences)</option>
<option value="long" ${settings.suggestion_length === 'long' ? 'selected' : ''}>Long (4-6 sentences)</option>
</select>
</div>
</div>
<div class="pw_setting_row pw_setting_row_stream">
<span class="pw_setting_label"><i class="fa-solid fa-stream"></i> Stream suggestions</span>
<div class="pw_toggle ${settings.stream_suggestions ? 'active' : ''}" data-setting="stream_suggestions"></div>
</div>
<p class="pw_setting_hint pw_setting_stream_hint">
Cards appear as each suggestion is generated. Works with Ollama and OpenAI-compatible APIs; Connection Profile may also support streaming.
</p>
<!-- Reasoning Mode Settings -->
<div class="pw_setting_row" style="margin-top: 12px;">
<span class="pw_setting_label"><i class="fa-solid fa-brain"></i> Reasoning Mode</span>
<div class="pw_toggle ${settings.reasoning_mode ? 'active' : ''}" data-setting="reasoning_mode"></div>
</div>
<p class="pw_setting_hint">
Enable for reasoning models (DeepSeek, etc.). Increases max tokens and properly handles thinking tags.
</p>
<div id="pw_modal_max_tokens_row" class="pw_setting_row" style="${settings.reasoning_mode ? 'display: flex' : 'display: none'}; margin-top: 8px;">
<span class="pw_setting_label"><i class="fa-solid fa-terminal"></i> Max Output Tokens</span>
<div class="pw_setting_control">
<input id="pw_modal_max_output_tokens" type="number" class="text_pole" value="${settings.max_output_tokens || 16384}" min="512" max="128000" step="512" style="width: 100px;"
title="Maximum tokens for reasoning model output">
</div>
</div>
<p id="pw_modal_max_tokens_hint" class="pw_setting_hint" style="${settings.reasoning_mode ? 'display: block' : 'display: none'}; margin-top: 4px;">
Higher values allow more thinking tokens for reasoning models. 8K-32K recommended.
</p>
</div>
<div class="pw_settings_section">
<h4 class="pw_settings_section_title">
<i class="fa-solid fa-book-open"></i> Context Sources
</h4>
<p style="color: var(--pw-text-muted); font-size: 0.8rem; margin-bottom: 10px;">
Include additional context for more accurate suggestions
</p>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-scroll"></i> Include Scenario</span>
<div class="pw_toggle ${settings.include_scenario ? 'active' : ''}" data-setting="include_scenario"></div>
</div>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-user"></i> Include Character Description</span>
<div class="pw_toggle ${settings.include_description ? 'active' : ''}" data-setting="include_description"></div>
</div>
<div class="pw_setting_row" style="flex-wrap: wrap;">
<div style="display: flex; justify-content: space-between; width: 100%; align-items: center;">
<span class="pw_setting_label"><i class="fa-solid fa-globe"></i> Include World Info Lorebook</span>
<div class="pw_toggle ${settings.include_worldinfo ? 'active' : ''}" data-setting="include_worldinfo"></div>
</div>
<div class="pw_warning_text" style="width: 100%; margin-top: 4px;">
<i class="fa-solid fa-triangle-exclamation"></i> Experimental: May decrease suggestion quality. Works only on entries with Order 250 or higher.
</div>
</div>
</div>
<div class="pw_settings_section">
<h4 class="pw_settings_section_title">
<i class="fa-solid fa-microchip"></i> Generation Source
</h4>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-server"></i> Source</span>
<div class="pw_setting_control">
<select id="pw_sm_source" class="pw_select text_pole">
<option value="default" ${settings.source === 'default' ? 'selected' : ''}>Default (Main API)</option>
<option value="profile" ${settings.source === 'profile' ? 'selected' : ''}>Connection Profile</option>
<option value="ollama" ${settings.source === 'ollama' ? 'selected' : ''}>Ollama</option>
<option value="openai" ${settings.source === 'openai' ? 'selected' : ''}>OpenAI Compatible</option>
</select>
</div>
</div>
<div class="pw_sm_provider_box" id="pw_sm_profile_box" style="${settings.source === 'profile' ? '' : 'display:none'}">
<div class="pw_sm_provider_row">
<label>Profile</label>
<select id="pw_sm_profile" class="pw_select text_pole">${profileOptions}</select>
</div>
</div>
<div class="pw_sm_provider_box" id="pw_sm_ollama_box" style="${settings.source === 'ollama' ? '' : 'display:none'}">
<div class="pw_sm_provider_row">
<label>URL</label>
<input type="text" id="pw_sm_ollama_url" value="${settings.ollama_url}" placeholder="http://localhost:11434">
</div>
<div class="pw_sm_provider_row">
<label>Model</label>
<select id="pw_sm_ollama_model" class="pw_select text_pole"></select>
</div>
</div>
<div class="pw_sm_provider_box" id="pw_sm_openai_box" style="${settings.source === 'openai' ? '' : 'display:none'}">
<div class="pw_sm_provider_row">
<label>URL</label>
<input type="text" id="pw_sm_openai_url" value="${settings.openai_url}" placeholder="http://localhost:1234/v1">
</div>
<div class="pw_sm_provider_row">
<input type="text" id="pw_sm_openai_model" value="${settings.openai_model}" placeholder="Model name">
</div>
<div class="pw_sm_provider_row">
<label>Key</label>
<input type="password" id="pw_sm_openai_key" value="${settings.openai_key}" placeholder="API Key (Optional)">
</div>
</div>
</div>
<div class="pw_settings_section">
<h4 class="pw_settings_section_title">
<i class="fa-solid fa-palette"></i> Appearance
</h4>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-text-height"></i> Font Size</span>
<div class="pw_setting_control">
<select id="pw_sm_font_size" class="pw_select text_pole">
<option value="small" ${settings.bar_font_size === 'small' ? 'selected' : ''}>Small</option>
<option value="default" ${settings.bar_font_size === 'default' ? 'selected' : ''}>Default</option>
<option value="large" ${settings.bar_font_size === 'large' ? 'selected' : ''}>Large</option>
</select>
</div>
</div>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-up-down"></i> Bar Height</span>
<div class="pw_setting_control">
<select id="pw_sm_bar_height" class="pw_select text_pole">
<option value="compact" ${settings.bar_height === 'compact' ? 'selected' : ''}>Compact</option>
<option value="default" ${settings.bar_height === 'default' ? 'selected' : ''}>Default</option>
<option value="max" ${settings.bar_height === 'max' ? 'selected' : ''}>Max</option>
</select>
</div>
</div>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-font"></i> Title font</span>
<div class="pw_setting_control">
<select id="pw_sm_bar_title_font" class="pw_select pw_title_font_select text_pole">
<option value="none" style="font-family: inherit" ${settings.bar_title_font === 'none' ? 'selected' : ''}>None (hidden)</option>
<option value="default" style="font-family: 'Crimson Text', Georgia, serif" ${settings.bar_title_font === 'default' ? 'selected' : ''}>Default</option>
<optgroup label="Serif">
<option value="crimson" style="font-family: 'Crimson Text', Georgia, serif" ${settings.bar_title_font === 'crimson' ? 'selected' : ''}>Crimson Text</option>
<option value="georgia" style="font-family: Georgia, 'Times New Roman', serif" ${settings.bar_title_font === 'georgia' ? 'selected' : ''}>Georgia</option>
<option value="merriweather" style="font-family: 'Merriweather', Georgia, serif" ${settings.bar_title_font === 'merriweather' ? 'selected' : ''}>Merriweather</option>
<option value="lora" style="font-family: 'Lora', Georgia, serif" ${settings.bar_title_font === 'lora' ? 'selected' : ''}>Lora</option>
</optgroup>
<optgroup label="Sans-serif">
<option value="inter" style="font-family: 'Inter', system-ui, sans-serif" ${settings.bar_title_font === 'inter' ? 'selected' : ''}>Inter</option>
<option value="nunito" style="font-family: 'Nunito', system-ui, sans-serif" ${settings.bar_title_font === 'nunito' ? 'selected' : ''}>Nunito</option>
<option value="poppins" style="font-family: 'Poppins', system-ui, sans-serif" ${settings.bar_title_font === 'poppins' ? 'selected' : ''}>Poppins</option>
<option value="roboto" style="font-family: 'Roboto', system-ui, sans-serif" ${settings.bar_title_font === 'roboto' ? 'selected' : ''}>Roboto</option>
</optgroup>
</select>
</div>
</div>
</div>
<div class="pw_settings_section">
<h4 class="pw_settings_section_title">
<i class="fa-solid fa-wand-sparkles"></i> Surprise Me
<span class="pw_setting_tooltip_icon" title="Surprise Me secretly injects an AI-generated suggestion into the chat context a set number of messages before it fires. Pick a style, and Pathweaver will quietly arm a hidden prompt. When the countdown hits, the suggestion appears naturally — like the story took an unexpected turn on its own.">?</span>
</h4>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-infinity"></i> Endless Surprises
<span class="pw_setting_tooltip_icon" title="Automatically generates and arms a fresh surprise the moment the previous one fires. The story never stops surprising you — until you turn this off.">?</span>
</span>
<div class="pw_toggle ${settings.surprise_endless ? 'active' : ''}" data-setting="surprise_endless"></div>
</div>
<p class="pw_setting_hint" style="margin: -4px 0 10px 0; font-size: 0.78rem; opacity: 0.8;">
A new surprise is silently queued the moment the previous one fires.
</p>
<div class="pw_setting_row">
<span class="pw_setting_label"><i class="fa-solid fa-shuffle"></i> Randomize depth</span>
<div class="pw_toggle ${settings.surprise_randomize ? 'active' : ''}" data-setting="surprise_randomize"></div>
</div>
<div id="pw_sm_surprise_range_rows" style="${settings.surprise_randomize ? '' : 'display:none;'}">
<p class="pw_setting_hint" style="margin: 2px 0 10px 0; font-size: 0.78rem; opacity: 0.8;">
Picks a random depth within this range each time.
</p>
<div class="pw_setting_row pw_surprise_depth_range_row">
<span class="pw_setting_label"><i class="fa-solid fa-clock-rotate-left"></i> Min messages</span>
<div class="pw_setting_control">
<select id="pw_sm_surprise_depth_min" class="pw_select text_pole">
${[2,3,4,5,6,7,8,9,10,11,12].map(n => `<option value="${n}" ${settings.surprise_depth_min == n ? 'selected' : ''}>${n}</option>`).join('')}
</select>
</div>
</div>
<div class="pw_setting_row pw_surprise_depth_range_row">
<span class="pw_setting_label"><i class="fa-solid fa-clock-rotate-left"></i> Max messages</span>
<div class="pw_setting_control">
<select id="pw_sm_surprise_depth_max" class="pw_select text_pole">
${[2,3,4,5,6,7,8,9,10,11,12].map(n => `<option value="${n}" ${settings.surprise_depth_max == n ? 'selected' : ''}>${n}</option>`).join('')}
</select>
</div>
</div>
</div>
<div id="pw_sm_surprise_fixed_hint" style="${settings.surprise_randomize ? 'display:none;' : ''}">
<p class="pw_setting_hint" style="margin: 2px 0 0; font-size: 0.78rem; opacity: 0.8;">
Uses a fixed depth of <strong>${settings.surprise_depth_min}</strong> messages.
</p>
</div>
<!-- Active queue accordion -->
<details id="pw_sm_surprise_queue_accordion" class="pw_surprise_queue_accordion" style="display:none; margin-top: 12px;">
<summary class="pw_surprise_queue_summary">
<i class="fa-solid fa-list-check"></i>
<span id="pw_sm_surprise_queue_label">Armed surprises</span>
<i class="fa-solid fa-chevron-down pw_surprise_queue_chevron"></i>
</summary>
<ul id="pw_sm_surprise_queue_list" class="pw_surprise_queue_list"></ul>
</details>
<!-- Clear all button -->
<button id="pw_sm_surprise_clear_all" class="pw_surprise_clear_all_btn" style="display:none; margin-top: 10px;">
<i class="fa-solid fa-xmark"></i> Clear all surprises
</button>
</div>
<div class="pw_settings_section">
<h4 class="pw_settings_section_title">
<i class="fa-solid fa-wand-magic-sparkles"></i> Suggestion Styles
</h4>
<p style="color: var(--pw-text-muted); font-size: 0.85rem; margin-bottom: 12px;">
Manage built-in and customized suggestion styles.
</p>
<button class="pw_open_editor_btn" id="pw_open_style_editor">
<i class="fa-solid fa-layer-group"></i> Suggestion Styles Manager
</button>
</div>
</div>
</div>
</div>
</div>`;
jQuery('body').append(modalHtml);
settingsModal = jQuery('#pw_settings_modal');
// Bind close
jQuery('#pw_close_settings').on('click', closeSettingsModal);
settingsModal.on('click', (e) => {
if (e.target === settingsModal[0]) closeSettingsModal();
});
// Toggle switches
jQuery('.pw_toggle').on('click', function () {
const setting = jQuery(this).data('setting');
settings[setting] = !settings[setting];
jQuery(this).toggleClass('active');
if (setting === 'enabled') {
createActionBar();
}
if (setting === 'show_explicit') createActionBar();
if (setting === 'hide_animated_bar') {
jQuery('.pw_action_bar').toggleClass('pw_hide_animated_bar', settings.hide_animated_bar);
}
if (setting === 'insert_type_enabled') {
if (settings[setting]) jQuery('#pw_sm_insert_type_options').css('display', 'flex');
else jQuery('#pw_sm_insert_type_options').hide();
}
// Mutually exclusive sub-options
if (setting === 'insert_type_ooc' && settings.insert_type_ooc) {
settings.insert_type_director = false;
jQuery('.pw_toggle[data-setting="insert_type_director"]').removeClass('active');
}
if (setting === 'insert_type_director' && settings.insert_type_director) {
settings.insert_type_ooc = false;
jQuery('.pw_toggle[data-setting="insert_type_ooc"]').removeClass('active');
}
// Surprise Me: show/hide range rows based on randomize toggle
if (setting === 'surprise_randomize') {
if (settings.surprise_randomize) {
jQuery('#pw_sm_surprise_range_rows').show();
jQuery('#pw_sm_surprise_fixed_hint').hide();
} else {
jQuery('#pw_sm_surprise_range_rows').hide();
jQuery('#pw_sm_surprise_fixed_hint').show();
jQuery('#pw_sm_surprise_fixed_hint p strong').text(settings.surprise_depth_min);
}
}
// Surprise Me: clear queue when endless is turned off so no
// lingering background generations can fire against local backends.
if (setting === 'surprise_endless' && !settings.surprise_endless) {
clearAllSurprises();
renderSurpriseQueue();
createActionBar();
}
// Reasoning mode: show/hide max tokens input
if (setting === 'reasoning_mode') {
if (settings.reasoning_mode) {
jQuery('#pw_modal_max_tokens_row').show();
jQuery('#pw_modal_max_tokens_hint').show();
} else {
jQuery('#pw_modal_max_tokens_row').hide();
jQuery('#pw_modal_max_tokens_hint').hide();
}
// Also sync to extension panel
jQuery('#pw_max_output_tokens_row').toggle(settings.reasoning_mode);
}
saveSettings();
syncSettingsToPanel(); // Sync to extension panel (NOW after logic)
});
// Source dropdown
jQuery('#pw_sm_source').on('change', function () {
settings.source = this.value;
saveSettings();
jQuery('#pw_sm_profile_box, #pw_sm_ollama_box, #pw_sm_openai_box').hide();
if (this.value === 'profile') jQuery('#pw_sm_profile_box').show();
else if (this.value === 'ollama') {
jQuery('#pw_sm_ollama_box').show();
refreshOllamaModels();
}
else if (this.value === 'openai') jQuery('#pw_sm_openai_box').show();
});
jQuery('#pw_sm_profile').on('change', function () { settings.preset = this.value; saveSettings(); syncSettingsToPanel(); });
jQuery('#pw_sm_ollama_url').on('change', function () { settings.ollama_url = this.value; saveSettings(); syncSettingsToPanel(); refreshOllamaModels(); });
jQuery('#pw_sm_ollama_model').on('change', function () { settings.ollama_model = this.value; saveSettings(); syncSettingsToPanel(); });
jQuery('#pw_sm_openai_url').on('change', function () { settings.openai_url = this.value; saveSettings(); syncSettingsToPanel(); });
jQuery('#pw_sm_openai_model').on('change', function () { settings.openai_model = this.value; saveSettings(); syncSettingsToPanel(); });
jQuery('#pw_sm_openai_key').on('change', function () { settings.openai_key = this.value; saveSettings(); syncSettingsToPanel(); });
jQuery('#pw_sm_suggestions').on('change', function () {
settings.suggestions_count = Math.max(1, Math.min(20, parseInt(this.value) || 10));
this.value = settings.suggestions_count;
saveSettings();
syncSettingsToPanel();
});
jQuery('#pw_sm_context').on('change', function () { settings.context_depth = parseInt(this.value) || 4; saveSettings(); syncSettingsToPanel(); });
// Suggestion length
jQuery('#pw_sm_suggestion_length').on('change', function () { settings.suggestion_length = this.value; saveSettings(); syncSettingsToPanel(); });
// Modal: Max output tokens
jQuery('#pw_modal_max_output_tokens').on('change', function () {
const val = parseInt(this.value) || 16384;
settings.max_output_tokens = Math.max(512, Math.min(128000, val));
this.value = settings.max_output_tokens;
saveSettings();
syncSettingsToPanel();
});
// Surprise Me: depth min select
jQuery('#pw_sm_surprise_depth_min').on('change', function () {
const val = parseInt(this.value) || 2;
settings.surprise_depth_min = val;
// Ensure max >= min
if (settings.surprise_depth_max < val) {
settings.surprise_depth_max = val;
jQuery('#pw_sm_surprise_depth_max').val(val);
}
saveSettings();
syncSettingsToPanel();
});
// Surprise Me: depth max select
jQuery('#pw_sm_surprise_depth_max').on('change', function () {
const val = parseInt(this.value) || 6;
settings.surprise_depth_max = val;
// Ensure min <= max
if (settings.surprise_depth_min > val) {
settings.surprise_depth_min = val;
jQuery('#pw_sm_surprise_depth_min').val(val);
}
saveSettings();
syncSettingsToPanel();
});
// Font size
jQuery('#pw_sm_font_size').on('change', function () {
settings.bar_font_size = this.value;
saveSettings();
syncSettingsToPanel();
createActionBar();
});
// Bar height
jQuery('#pw_sm_bar_height').on('change', function () {
settings.bar_height = this.value;
saveSettings();
syncSettingsToPanel();
createActionBar();
});
// Bar title font
jQuery('#pw_sm_bar_title_font').on('change', function () {
settings.bar_title_font = this.value;
applyTitleFontSelectDisplay(this);
saveSettings();
syncSettingsToPanel();
createActionBar();
});
applyTitleFontSelectDisplay(document.getElementById('pw_sm_bar_title_font'));
// Style editor opener
jQuery('#pw_open_style_editor').on('click', () => openStyleEditor());
// Surprise Me: clear all (modal)
jQuery('#pw_sm_surprise_clear_all').on('click', function () {
clearAllSurprises();
renderSurpriseQueue();
createActionBar();
showToast('Surprises cleared!');
});
// Surprise Me: remove individual item from modal list
jQuery('#pw_sm_surprise_queue_list').on('click', '.pw_sq_remove', function () {
const idx = parseInt(jQuery(this).data('surprise-index'));
if (!isNaN(idx) && idx >= 0 && idx < activeSurprises.length) {
const s = activeSurprises[idx];
if (s.injected) clearSurprisePrompt(s.key);
activeSurprises.splice(idx, 1);
saveSurpriseQueue();
renderSurpriseQueue();
createActionBar();
showToast('Surprise removed.');
}
});
if (settings.source === 'ollama') refreshOllamaModels();
// Populate queue display now that modal elements exist in the DOM
renderSurpriseQueue();
}
async function refreshOllamaModels() {
const select = jQuery('#pw_sm_ollama_model');
select.html('<option value="">Loading...</option>');
const models = await fetchOllamaModels();
select.empty();
if (models.length) {
models.forEach(m => {
const selected = settings.ollama_model === m.name ? ' selected' : '';
select.append(`<option value="${m.name}"${selected}>${m.name}</option>`);
});
} else {
select.append('<option value="">No models found</option>');
}
}
function openSettingsModal() {
createSettingsModal();
settingsModal.addClass('active');
}
function closeSettingsModal() {
if (settingsModal) settingsModal.removeClass('active');
}
// ============================================================
// UI - STYLES MANAGER MODAL (Complete Redesign)
// ============================================================
let stylesManagerModal = null;
let currentEditStyle = null;
let originalBuiltinPrompts = {}; // Cache original prompts for reset
// Default template for new custom styles
const defaultTemplate = `You are a creative writing assistant generating story suggestions.
TASK: Generate suggestions for [YOUR THEME/CATEGORY HERE].
TYPES TO INCLUDE:
- [Type 1]: (description)
- [Type 2]: (description)
- [Type 3]: (description)
OUTPUT FORMAT:
[EMOJI] TITLE
DESCRIPTION
---
(Repeat for each suggestion)
GUIDELINES:
- Each suggestion should be distinct and creative
- Keep titles punchy (under 8 words) - use plain text only, NO markdown
- Match the tone and genre of the ongoing story
- Do NOT include numbering or preamble`;
function openStyleEditor() {
openStylesManager();
}
function openStylesManager() {
if (jQuery('#pw_styles_manager').length) {
jQuery('#pw_styles_manager').remove();
}
const modalHtml = `
<div class="pw_modal_overlay" id="pw_styles_manager">
<div class="pw_modal pw_manager_modal">
<div class="pw_modal_header">
<h3 class="pw_modal_title">
<i class="fa-solid fa-wand-magic-sparkles"></i>
Suggestion Styles Manager
</h3>
<button class="pw_modal_close" id="pw_close_manager">&times;</button>
</div>
<div class="pw_modal_body">
<div class="pw_manager_container">
<div class="pw_manager_flipper" id="pw_manager_flipper">
<!-- FRONT: List View -->
<div class="pw_manager_front" id="pw_manager_list_view">
<div class="pw_manager_header">
<h4><i class="fa-solid fa-layer-group"></i> All Styles</h4>
<button class="pw_create_btn" id="pw_create_new_style">
<i class="fa-solid fa-plus"></i> Create New
</button>
</div>
<div class="pw_style_list" id="pw_style_list"></div>
</div>
<!-- BACK: Editor View -->
<div class="pw_manager_back" id="pw_manager_editor_view">
<div class="pw_editor_back_header">
<button class="pw_back_btn" id="pw_back_to_list" title="Back to list">
<i class="fa-solid fa-arrow-left"></i>
</button>
<span class="pw_editor_back_title" id="pw_editor_title">Edit Style</span>
</div>
<div class="pw_editor_content">
<div class="pw_editor_row">
<label>Name</label>
<input type="text" id="pw_edit_name" placeholder="Style Name" maxlength="30">
</div>
<div class="pw_editor_row">
<label>Icon</label>
<div class="pw_icon_dropdown_wrap">
<select id="pw_edit_icon" style="display:none;"></select>
<button type="button" class="pw_icon_dropdown_trigger" id="pw_icon_dropdown" data-value="fa-star">
<i class="fa-solid fa-star pw_icon_dd_icon"></i>
<span class="pw_icon_dd_label">star</span>
<i class="fa-solid fa-chevron-down pw_icon_dd_chevron"></i>
</button>
<div class="pw_icon_dropdown_panel" id="pw_icon_panel">
<div class="pw_icon_mobile_header">
<div class="pw_icon_mobile_handle"></div>
<span class="pw_icon_mobile_title">Choose Icon</span>
<button type="button" class="pw_icon_mobile_close" id="pw_icon_mobile_close" aria-label="Close icon picker">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="pw_icon_search_wrap">
<i class="fa-solid fa-magnifying-glass"></i>
<input type="text" class="pw_icon_search" id="pw_icon_search" placeholder="Search icons...">
</div>
<div class="pw_icon_grid" id="pw_icon_grid"></div>
</div>
</div>
</div>
<div class="pw_editor_row" style="flex-direction: column; align-items: flex-start; flex: 1;">
<label style="margin-bottom: 8px;">System Prompt</label>
<textarea class="pw_editor_textarea" id="pw_edit_prompt" placeholder="Enter system prompt..."></textarea>
</div>
</div>
<div class="pw_editor_toolbar">
<button class="pw_toolbar_btn primary" id="pw_save_style">
<i class="fa-solid fa-check"></i> Save
</button>
<button class="pw_toolbar_btn" id="pw_copy_prompt">
<i class="fa-solid fa-copy"></i> Copy
</button>
<button class="pw_toolbar_btn" id="pw_paste_prompt">
<i class="fa-solid fa-paste"></i> Paste
</button>
<button class="pw_toolbar_btn" id="pw_reset_prompt">
<i class="fa-solid fa-rotate-left"></i> Reset
</button>
<button class="pw_toolbar_btn" id="pw_export_prompt">
<i class="fa-solid fa-download"></i> Export
</button>
<button class="pw_toolbar_btn danger" id="pw_delete_style">
<i class="fa-solid fa-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>`;
jQuery('body').append(modalHtml);
stylesManagerModal = jQuery('#pw_styles_manager');
// Populate hidden select (for value tracking) and custom icon grid
const iconSelect = jQuery('#pw_edit_icon');
const iconGrid = jQuery('#pw_icon_grid');
AVAILABLE_ICONS.forEach(icon => {
const label = icon.replace('fa-', '');
iconSelect.append(`<option value="${icon}">${label}</option>`);
iconGrid.append(`
<button type="button" class="pw_icon_grid_item" data-icon="${icon}" title="${label}">
<i class="fa-solid ${icon}"></i>
<span>${label}</span>
</button>
`);
});
// Render the styles list
renderStylesList();
// Bind events
bindStylesManagerEvents();
// Show modal
stylesManagerModal.addClass('active');
}
function renderStylesList() {
const listContainer = jQuery('#pw_style_list');
listContainer.empty();
// getAllCategories() already merges builtin_icon_customizations so icons reflect saved overrides
const allCats = getAllCategories();
// Built-in styles first
for (const [key, cat] of Object.entries(MAIN_CATEGORIES)) {
if (cat.nsfw && !settings.show_explicit) continue;
const displayIcon = allCats[key]?.icon || cat.icon;
listContainer.append(`
<div class="pw_style_item builtin" data-style-id="${key}" data-builtin="true">
<div class="pw_style_icon">
<i class="fa-solid ${displayIcon}"></i>
</div>
<div class="pw_style_info">
<div class="pw_style_name">${cat.name}</div>
<div class="pw_style_type">Main Style</div>
</div>
<div class="pw_style_actions">
<button class="pw_style_action_btn pw_edit_style_btn" title="Edit">
<i class="fa-solid fa-pen"></i>
</button>
</div>
</div>
`);
}
// Genre styles
for (const [key, cat] of Object.entries(GENRE_CATEGORIES)) {
if (cat.nsfw && !settings.show_explicit) continue;
const displayIcon = allCats[key]?.icon || cat.icon;
listContainer.append(`
<div class="pw_style_item builtin" data-style-id="${key}" data-builtin="true">
<div class="pw_style_icon">
<i class="fa-solid ${displayIcon}"></i>
</div>
<div class="pw_style_info">
<div class="pw_style_name">${cat.name}</div>
<div class="pw_style_type">Genre Style</div>
</div>
<div class="pw_style_actions">
<button class="pw_style_action_btn pw_edit_style_btn" title="Edit">
<i class="fa-solid fa-pen"></i>
</button>
</div>
</div>
`);
}
// Custom styles
if (settings.custom_styles?.length) {
settings.custom_styles.forEach(style => {
listContainer.append(`
<div class="pw_style_item custom" data-style-id="${style.id}" data-builtin="false">
<div class="pw_style_icon">
<i class="fa-solid ${style.icon}"></i>
</div>
<div class="pw_style_info">
<div class="pw_style_name">${style.name}</div>
<div class="pw_style_type">Custom Style</div>
</div>
<div class="pw_style_actions">
<button class="pw_style_action_btn pw_edit_style_btn" title="Edit">
<i class="fa-solid fa-pen"></i>
</button>
<button class="pw_style_action_btn danger pw_delete_style_btn" title="Delete">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
`);
});
}
}
function bindStylesManagerEvents() {
// Close modal
jQuery('#pw_close_manager').on('click', closeStylesManager);
stylesManagerModal.on('click', (e) => {
if (e.target === stylesManagerModal[0]) closeStylesManager();
});
// Create new style
jQuery('#pw_create_new_style').on('click', () => {
openEditorView(null, true);
});
// Edit style from list
jQuery('#pw_style_list').on('click', '.pw_edit_style_btn', function (e) {
e.stopPropagation();
const item = jQuery(this).closest('.pw_style_item');
const styleId = item.data('style-id');
const isBuiltin = String(item.data('builtin')) === 'true';
openEditorView(styleId, false, isBuiltin);
});
// Delete style from list
jQuery('#pw_style_list').on('click', '.pw_delete_style_btn', function (e) {
e.stopPropagation();
const item = jQuery(this).closest('.pw_style_item');
const styleId = item.data('style-id');
deleteStyle(styleId);
});
// Back to list
jQuery('#pw_back_to_list').on('click', () => {
jQuery('#pw_manager_flipper').removeClass('flipped');
currentEditStyle = null;
});
// Remove any prior document handlers before re-adding (modal is recreated each open)
jQuery(document)
.off('click.pw_icon_dd')
.off('click.pw_icon_dd_outside')
.off('input.pw_icon_search')
.off('click.pw_icon_grid');
// Shared close helper — removes open state and mobile backdrop class
function closeIconPanel() {
jQuery('#pw_icon_panel').removeClass('open pw_icon_panel_flip pw_icon_panel_up');
jQuery('body').removeClass('pw_icon_dd_open');
}
// Custom icon dropdown trigger opens/closes the panel
jQuery(document).on('click.pw_icon_dd', '#pw_icon_dropdown', function (e) {
e.stopPropagation();
const panel = jQuery('#pw_icon_panel');
const isOpen = panel.hasClass('open');
const isMobile = window.innerWidth <= 768;
if (!isOpen) {
panel.removeClass('pw_icon_panel_flip pw_icon_panel_up');
panel.css({ top: '', bottom: '', left: '', right: '', transform: '' });
panel.addClass('open');
if (isMobile) {
// Mobile bottom-sheet — CSS handles positioning, just add
// the body class to activate the dimming backdrop.
jQuery('body').addClass('pw_icon_dd_open');
} else {
// Desktop: measure and apply flip/up corrections as normal
requestAnimationFrame(() => {
const rect = panel[0].getBoundingClientRect();
const wrap = panel.closest('.pw_icon_dropdown_wrap')[0];
const wrapRect = wrap ? wrap.getBoundingClientRect() : rect;
if (rect.right > window.innerWidth - 8) {
panel.addClass('pw_icon_panel_flip');
}
if (rect.bottom > window.innerHeight - 8 && wrapRect.top > rect.height + 12) {
panel.addClass('pw_icon_panel_up');
}
});
}
jQuery('#pw_icon_search').val('').trigger('input').focus();
} else {
closeIconPanel();
}
});
// Mobile sheet close button (×) — fires before the outside-click guard
jQuery(document).on('click.pw_icon_dd', '#pw_icon_mobile_close', function (e) {
e.stopPropagation();
closeIconPanel();
});
// Close when tapping the dimming backdrop.
// Guard: ignore clicks that land on the panel or the trigger button.
jQuery(document).on('click.pw_icon_dd_outside', function (e) {
if (!jQuery(e.target).closest('.pw_icon_dropdown_panel, .pw_icon_dropdown_wrap').length) {
closeIconPanel();
}
});
// Search filter
jQuery(document).on('input.pw_icon_search', '#pw_icon_search', function () {
const q = this.value.toLowerCase();
jQuery('#pw_icon_grid .pw_icon_grid_item').each(function () {
const match = jQuery(this).data('icon').replace('fa-', '').includes(q);
jQuery(this).toggle(match);
});
});
// Select icon from grid
jQuery(document).on('click.pw_icon_grid', '#pw_icon_grid .pw_icon_grid_item', function (e) {
e.stopPropagation();
const icon = jQuery(this).data('icon');
setIconDropdown(icon);
closeIconPanel();
});
// Save style
jQuery('#pw_save_style').on('click', saveCurrentStyle);
// Copy prompt
jQuery('#pw_copy_prompt').on('click', () => {
const prompt = jQuery('#pw_edit_prompt').val();
navigator.clipboard.writeText(prompt).then(() => showToast('Prompt copied!'));
});
// Paste prompt
jQuery('#pw_paste_prompt').on('click', async () => {
try {
const text = await navigator.clipboard.readText();
jQuery('#pw_edit_prompt').val(text);
showToast('Prompt pasted!');
} catch (err) {
showToast('Could not read clipboard');
}
});
// Reset prompt + icon (built-in only)
jQuery('#pw_reset_prompt').on('click', async () => {
if (currentEditStyle && currentEditStyle.builtin) {
const original = await loadBuiltinPrompt(currentEditStyle.id);
jQuery('#pw_edit_prompt').val(original);
// Also restore the original default icon
const allCatsOrig = { ...MAIN_CATEGORIES, ...GENRE_CATEGORIES };
const originalIcon = allCatsOrig[currentEditStyle.id]?.icon || 'fa-star';
setIconDropdown(originalIcon);
showToast('Reset to default!');
} else {
jQuery('#pw_edit_prompt').val(defaultTemplate);
showToast('Reset to template!');
}
});
// Export prompt
jQuery('#pw_export_prompt').on('click', () => {
const name = jQuery('#pw_edit_name').val().trim() || 'style';
const prompt = jQuery('#pw_edit_prompt').val();
const blob = new Blob([prompt], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name.replace(/\s+/g, '_').toLowerCase()}.md`;
a.click();
URL.revokeObjectURL(url);
showToast('Exported!');
});
// Delete style (from editor)
jQuery('#pw_delete_style').on('click', () => {
if (currentEditStyle && !currentEditStyle.builtin) {
deleteStyle(currentEditStyle.id);
jQuery('#pw_manager_flipper').removeClass('flipped');
} else {
showToast('Cannot delete built-in styles');
}
});
}
/** Update the custom icon dropdown trigger UI and hidden select value */
function setIconDropdown(icon) {
const safe = icon || 'fa-star';
const label = safe.replace('fa-', '');
const trigger = jQuery('#pw_icon_dropdown');
trigger.data('value', safe);
trigger.find('.pw_icon_dd_icon').removeClass().addClass(`fa-solid ${safe} pw_icon_dd_icon`);
trigger.find('.pw_icon_dd_label').text(label);
// Sync hidden select
jQuery('#pw_edit_icon').val(safe);
// Highlight active item in grid
jQuery('#pw_icon_grid .pw_icon_grid_item').removeClass('active');
jQuery(`#pw_icon_grid .pw_icon_grid_item[data-icon="${safe}"]`).addClass('active');
}
async function openEditorView(styleId, isNew = false, isBuiltin = false) {
const flipper = jQuery('#pw_manager_flipper');
const nameInput = jQuery('#pw_edit_name');
const iconSelect = jQuery('#pw_edit_icon');
const promptArea = jQuery('#pw_edit_prompt');
const deleteBtn = jQuery('#pw_delete_style');
const titleEl = jQuery('#pw_editor_title');
if (isNew) {
// Creating new style
currentEditStyle = { id: null, builtin: false, isNew: true };
titleEl.text('Create New Suggestion Style');
nameInput.val('').prop('disabled', false);
setIconDropdown('fa-star');
promptArea.val(defaultTemplate);
deleteBtn.hide();
} else if (isBuiltin) {
// Editing built-in style
const allCats = getAllCategories();
const cat = allCats[styleId];
currentEditStyle = { id: styleId, builtin: true, isNew: false };
titleEl.text(`Edit: ${cat.name}`);
nameInput.val(cat.name).prop('disabled', true);
// Use the saved icon override if present, otherwise fall back to category default
const savedIcon = settings.builtin_icon_customizations?.[styleId] || cat.icon;
setIconDropdown(savedIcon);
// Load the prompt (check for user customization first)
let prompt = promptCache[styleId];
if (!prompt) {
prompt = await loadPrompt(styleId);
}
promptArea.val(prompt);
deleteBtn.hide();
} else {
// Editing custom style
const style = settings.custom_styles.find(s => s.id === styleId);
if (!style) return;
currentEditStyle = { id: styleId, builtin: false, isNew: false };
titleEl.text(`Edit: ${style.name}`);
nameInput.val(style.name).prop('disabled', false);
setIconDropdown(style.icon || 'fa-star');
promptArea.val(style.prompt);
deleteBtn.show();
}
flipper.addClass('flipped');
}
function saveCurrentStyle() {
const name = jQuery('#pw_edit_name').val().trim();
// Read icon from our custom dropdown (hidden select is kept in sync)
const icon = jQuery('#pw_edit_icon').val() || jQuery('#pw_icon_dropdown').data('value') || 'fa-star';
const prompt = jQuery('#pw_edit_prompt').val().trim();
if (!currentEditStyle) return;
if (currentEditStyle.builtin) {
// Save customization for built-in style (prompt + icon)
promptCache[currentEditStyle.id] = prompt;
if (!settings.builtin_customizations) settings.builtin_customizations = {};
settings.builtin_customizations[currentEditStyle.id] = prompt;
// Save icon customization
const allCatsOrig = { ...MAIN_CATEGORIES, ...GENRE_CATEGORIES };
const originalIcon = allCatsOrig[currentEditStyle.id]?.icon;
if (!settings.builtin_icon_customizations) settings.builtin_icon_customizations = {};
if (icon && icon !== originalIcon) {
settings.builtin_icon_customizations[currentEditStyle.id] = icon;
} else {
delete settings.builtin_icon_customizations[currentEditStyle.id];
}
saveSettings();
showToast('Built-in style customized!');
} else {
// Custom style
if (!name) { showToast('Please enter a name'); return; }
if (!prompt) { showToast('Please enter a prompt'); return; }
const id = currentEditStyle.isNew ? 'custom_' + Date.now() : currentEditStyle.id;
const newStyle = { id, name, icon, prompt };
if (!settings.custom_styles) settings.custom_styles = [];
if (currentEditStyle.isNew) {
settings.custom_styles.push(newStyle);
} else {
const idx = settings.custom_styles.findIndex(s => s.id === currentEditStyle.id);
if (idx >= 0) settings.custom_styles[idx] = newStyle;
}
delete promptCache[id];
saveSettings();
showToast('Style saved!');
}
createActionBar();
renderStylesList();
jQuery('#pw_manager_flipper').removeClass('flipped');
currentEditStyle = null;
}
function deleteStyle(styleId) {
settings.custom_styles = settings.custom_styles.filter(s => s.id !== styleId);
delete promptCache[styleId];
delete cachedSuggestions[styleId];
saveSettings();
createActionBar();
renderStylesList();
showToast('Style deleted');
}
async function loadBuiltinPrompt(category) {
// Check if we have original cached
if (originalBuiltinPrompts[category]) {
return originalBuiltinPrompts[category];
}
// Load from file
try {
const response = await fetch(`${BASE_URL}/prompts/${category}.md`);
if (response.ok) {
const text = await response.text();
originalBuiltinPrompts[category] = text;
return text;
}
} catch (err) {
warn('Failed to load built-in prompt:', err);
}
return defaultTemplate;
}
function closeStylesManager() {
if (stylesManagerModal) {
stylesManagerModal.removeClass('active');
// Reset flip state
jQuery('#pw_manager_flipper').removeClass('flipped');
currentEditStyle = null;
}
}
function closeStyleEditor() {
closeStylesManager();
}
// ============================================================
// SURPRISE FEATURE
// ============================================================
/**
* Inject a hidden prompt into the ST context at a given chat depth.
* Uses setExtensionPrompt so it is invisible to the user in the chat log.
*/
/**
* Resolve extension_prompt_types from ST context or window globals.
* ST exports these from script.js; they may be on the context object or window.
* Fallback to numeric constants: IN_CHAT=1, NONE=0
*/
function getSurprisePromptTypes() {
const stContext = SillyTavern.getContext();
// Try context first (some ST versions expose them here)
if (stContext?.extension_prompt_types) return stContext.extension_prompt_types;
// Try window globals (ST may export them)
if (typeof window.extension_prompt_types !== 'undefined') return window.extension_prompt_types;
// Numeric fallback matching ST's enum values
return { NONE: 0, IN_CHAT: 1, BEFORE_PROMPT: 2, AFTER_PROMPT: 3 };
}
function getSurprisePromptRoles() {
const stContext = SillyTavern.getContext();
if (stContext?.extension_prompt_roles) return stContext.extension_prompt_roles;
if (typeof window.extension_prompt_roles !== 'undefined') return window.extension_prompt_roles;
// Numeric fallback: SYSTEM=0, USER=1, ASSISTANT=2
return { SYSTEM: 0, USER: 1, ASSISTANT: 2 };
}
function injectSurprisePrompt(text, depth, key) {
try {
const stContext = SillyTavern.getContext();
if (!stContext || typeof stContext.setExtensionPrompt !== 'function') {
warn('setExtensionPrompt not available');
return false;
}
const promptTypes = getSurprisePromptTypes();
const promptRoles = getSurprisePromptRoles();
stContext.setExtensionPrompt(key, text, promptTypes.IN_CHAT, depth, true, promptRoles.SYSTEM);
log('Surprise injected at depth', depth, 'key:', key, ':', text.substring(0, 80) + '...');
return true;
} catch (err) {
warn('Failed to inject surprise prompt:', err);
return false;
}
}
/** Remove a single surprise injection by its unique key. */
function clearSurprisePrompt(key) {
try {
const stContext = SillyTavern.getContext();
if (!stContext || typeof stContext.setExtensionPrompt !== 'function') return;
const promptTypes = getSurprisePromptTypes();
stContext.setExtensionPrompt(key, '', promptTypes.NONE, 0);
log('Surprise prompt cleared:', key);
} catch (err) {
warn('Failed to clear surprise prompt:', err);
}
}
/** Clear every active surprise injection and reset the in-memory queue. */
function clearAllSurprises() {
for (const s of activeSurprises) {
if (s.injected) clearSurprisePrompt(s.key);
}
activeSurprises = [];
saveSurpriseQueue();
}
/** Persist the queue to chatMetadata. The `injected` flag is omitted intentionally. */
function saveSurpriseQueue() {
try {
const stContext = SillyTavern.getContext();
if (!stContext?.chatMetadata) return;
stContext.chatMetadata.pathweaver_surprises = activeSurprises.map(s => ({
key: s.key, category: s.category,
triggerAfter: s.triggerAfter, baseMessageCount: s.baseMessageCount, text: s.text
}));
if (typeof stContext.saveMetadataDebounced === 'function') stContext.saveMetadataDebounced();
log('Surprise queue saved:', activeSurprises.length, 'items');
} catch (err) { warn('Failed to save surprise queue:', err); }
}
/** Restore the queue from chatMetadata. All entries start as not-yet-injected. */
function loadSurpriseQueue() {
try {
const stContext = SillyTavern.getContext();
const saved = stContext?.chatMetadata?.pathweaver_surprises;
if (!saved?.length) { activeSurprises = []; return; }
activeSurprises = saved.map(s => ({ ...s, injected: false }));
for (const s of activeSurprises) {
const n = parseInt(s.key.replace('pathweaver_surprise_', ''));
if (!isNaN(n) && n >= surpriseKeyCounter) surpriseKeyCounter = n + 1;
}
log('Surprise queue restored:', activeSurprises.length, 'items');
} catch (err) { warn('Failed to load surprise queue:', err); activeSurprises = []; }
}
/** Render the queue into both the settings panel and modal accordions. */
function renderSurpriseQueue() {
const allCategories = getAllCategories();
const stContext = SillyTavern.getContext();
const currentCount = stContext?.chat?.length ?? 0;
const hasAny = activeSurprises.length > 0;
const listHtml = activeSurprises.map((s, i) => {
const cat = allCategories[s.category] || { name: s.category, icon: 'fa-wand-sparkles' };
const remaining = Math.max(0, s.triggerAfter - (currentCount - s.baseMessageCount));
const statusHtml = s.injected
? `<span class="pw_sq_status pw_sq_firing"><i class="fa-solid fa-bolt"></i> Firing now</span>`
: `<span class="pw_sq_status"><i class="fa-solid fa-hourglass-half"></i> ~${remaining} msg${remaining !== 1 ? 's' : ''}</span>`;
return `<li class="pw_sq_item">
<span class="pw_sq_cat"><i class="fa-solid ${cat.icon}"></i> ${cat.name}</span>
${statusHtml}
<button class="pw_sq_remove" data-surprise-index="${i}" title="Cancel this surprise">
<i class="fa-solid fa-xmark"></i>
</button>
</li>`;
}).join('');
const labelText = `Armed surprises (${activeSurprises.length})`;
// Settings panel (settings.html — always in DOM)
jQuery('#pw_surprise_queue_list').html(listHtml);
jQuery('#pw_surprise_queue_label').text(labelText);
jQuery('#pw_surprise_queue_accordion').css('display', hasAny ? '' : 'none');
jQuery('#pw_surprise_clear_all').css('display', hasAny ? '' : 'none');
// Settings modal (only present when open)
jQuery('#pw_sm_surprise_queue_list').html(listHtml);
jQuery('#pw_sm_surprise_queue_label').text(labelText);
jQuery('#pw_sm_surprise_queue_accordion').css('display', hasAny ? '' : 'none');
jQuery('#pw_sm_surprise_clear_all').css('display', hasAny ? '' : 'none');
}
/**
* Generate a single hidden narrative event using the given category's system prompt.
* Returns the raw text string (not parsed into suggestion cards).
*/
async function generateSurpriseText(category, signal) {
const stContext = SillyTavern.getContext();
if (!stContext) throw new Error('SillyTavern context not available');
// Guard for the default (generateRaw) source — it shares the same backend
// pipeline as the main chat generation. If a generation is already in
// progress, abort immediately to prevent concurrent requests crashing
// local backends such as KoboldCPP.
if (settings.source === 'default' && isGenerating) {
throw new DOMException('Generation already in progress', 'AbortError');
}
const storyContext = extractContext();
if (!storyContext) throw new Error('No active conversation found. Start a chat first.');
let categoryPrompt = await loadPrompt(category);
// Macro substitution
const charName = storyContext.characterInfo.replace('Character: ', '') || 'Character';
const userName = stContext.name1 || 'User';
categoryPrompt = categoryPrompt
.replace(/{{char}}/g, charName)
.replace(/{{user}}/g, userName)
.replace(/{{model}}/g, charName);
let contextBlock = '';
if (storyContext.characterInfo) contextBlock += `${storyContext.characterInfo}\n\n`;
if (settings.include_scenario && storyContext.scenario) contextBlock += `Scenario: ${storyContext.scenario}\n\n`;
if (settings.include_description && storyContext.description) {
contextBlock += `Character Description: ${storyContext.description.substring(0, 5000)}\n\n`;
}
contextBlock += `Recent conversation:\n${storyContext.history}`;
const userPrompt = `[STORY CONTEXT]\n${contextBlock}\n\n[TASK]\nGenerate exactly ONE single, self-contained narrative event or development that could be secretly injected into this story. This will be used as a hidden system note that the AI will act upon at the right moment.\n\nWrite it as a concise system instruction (1-3 sentences) in the format:\n[System Note: <the secret event/development>]\n\nMake it specific, surprising, and narratively interesting. Do NOT include any preamble, explanation, or multiple options — just the single system note.`;
const calculatedMaxTokens = 300;
let result = '';
if (settings.source === 'profile' && settings.preset) {
const cm = stContext.extensionSettings?.connectionManager;
const profile = cm?.profiles?.find(p => p.name === settings.preset);
if (!profile) throw new Error(`Profile '${settings.preset}' not found`);
if (!stContext.ConnectionManagerRequestService) throw new Error('ConnectionManagerRequestService not available');
const messages = [
{ role: 'system', content: categoryPrompt },
{ role: 'user', content: userPrompt }
];
const response = await stContext.ConnectionManagerRequestService.sendRequest(
profile.id, messages, calculatedMaxTokens,
{ stream: false, signal, extractData: true, includePreset: true, includeInstruct: true }
);
if (response?.content) result = response.content;
else if (typeof response === 'string') result = response;
else if (response?.choices?.[0]?.message?.content) result = response.choices[0].message.content;
else result = JSON.stringify(response);
} else if (settings.source === 'ollama') {
const baseUrl = (settings.ollama_url || 'http://localhost:11434').replace(/\/$/, '');
if (!settings.ollama_model) throw new Error('No Ollama model selected');
const response = await fetch(`${baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: settings.ollama_model,
system: categoryPrompt,
prompt: userPrompt,
stream: false,
options: { num_ctx: 4096, num_predict: calculatedMaxTokens }
}),
signal
});
if (!response.ok) throw new Error(`Ollama API error: ${response.status}`);
const data = await response.json();
result = data.response || '';
} else if (settings.source === 'openai') {
const baseUrl = (settings.openai_url || 'http://localhost:1234/v1').replace(/\/$/, '');
const headers = { 'Content-Type': 'application/json' };
if (settings.openai_key) headers['Authorization'] = `Bearer ${settings.openai_key}`;
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({
model: settings.openai_model || 'local-model',
messages: [
{ role: 'system', content: categoryPrompt },
{ role: 'user', content: userPrompt }
],
temperature: 0.9,
max_tokens: calculatedMaxTokens,
stream: false
}),
signal
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
const data = await response.json();
result = data.choices?.[0]?.message?.content || '';
} else {
const { generateRaw } = stContext;
if (!generateRaw) throw new Error('generateRaw not available in context');
const abortPromise = new Promise((_, reject) => {
if (signal) signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
});
result = await Promise.race([
generateRaw({ systemPrompt: categoryPrompt, prompt: userPrompt, streaming: false }),
abortPromise
]);
}
// Clean up the result
result = result
.replace(/<(thought|think|thinking|reasoning|reason)>[\s\S]*?<\/\1>/gi, '')
.trim();
if (!result) throw new Error('No content generated');
return result;
}
/**
* Show the Surprise modal: style picker → processing → confirmation.
*/
function showSurpriseModal(category) {
// Remove any existing surprise modal
jQuery('#pw_surprise_modal').remove();
const allCategories = getAllCategories();
const catInfo = allCategories[category] || { name: category, icon: 'fa-wand-sparkles' };
const modalHtml = `
<div class="pw_modal_overlay pw_surprise_modal_overlay" id="pw_surprise_modal">
<div class="pw_modal pw_surprise_modal_inner">
<div class="pw_modal_header">
<h3 class="pw_modal_title">
<i class="fa-solid fa-wand-sparkles pw_surprise_icon_spin"></i>
Surprise Me
</h3>
<button class="pw_modal_close" id="pw_close_surprise">&times;</button>
</div>
<div class="pw_modal_body" id="pw_surprise_modal_body">
<!-- Processing state -->
<div id="pw_surprise_processing" class="pw_surprise_processing">
<div class="pw_surprise_spinner">
<i class="fa-solid fa-circle-notch pw_spin pw_surprise_spin_icon"></i>
</div>
<div class="pw_surprise_processing_text">
<strong>Crafting your surprise...</strong>
<span class="pw_surprise_style_label">
<i class="fa-solid ${catInfo.icon}"></i> ${catInfo.name}
</span>
<p class="pw_surprise_hint">The AI is secretly planning something. You won't know what it is until it happens.</p>
</div>
<button class="pw_header_btn" id="pw_surprise_cancel_gen" style="margin-top: 16px;">
<i class="fa-solid fa-xmark"></i> Cancel
</button>
</div>
<!-- Done state (hidden initially) -->
<div id="pw_surprise_done" class="pw_surprise_done" style="display:none;">
<div class="pw_surprise_done_icon">
<i class="fa-solid fa-circle-check"></i>
</div>
<div class="pw_surprise_done_text">
<strong>Your surprise is set!</strong>
<p>Something unexpected has been secretly woven into the story. It will emerge within the next few messages — keep writing and see what happens.</p>
<span class="pw_surprise_style_label">
<i class="fa-solid ${catInfo.icon}"></i> ${catInfo.name} style
</span>
</div>
<button class="pw_header_btn primary" id="pw_surprise_ok" style="margin-top: 20px; width: 100%; justify-content: center;">
<i class="fa-solid fa-check"></i> OK, let's go!
</button>
</div>
<!-- Error state (hidden initially) -->
<div id="pw_surprise_error" class="pw_surprise_error" style="display:none;">
<div class="pw_surprise_error_icon">
<i class="fa-solid fa-circle-exclamation"></i>
</div>
<p id="pw_surprise_error_msg">Something went wrong.</p>
<button class="pw_header_btn" id="pw_surprise_retry" style="margin-top: 12px;">
<i class="fa-solid fa-rotate"></i> Retry
</button>
</div>
</div>
</div>
</div>`;
jQuery('body').append(modalHtml);
const modal = jQuery('#pw_surprise_modal');
// Close handlers
jQuery('#pw_close_surprise').on('click', () => {
if (surpriseAbortController) surpriseAbortController.abort();
modal.removeClass('active');
setTimeout(() => modal.remove(), 300);
});
modal.on('click', (e) => {
if (e.target === modal[0]) {
if (surpriseAbortController) surpriseAbortController.abort();
modal.removeClass('active');
setTimeout(() => modal.remove(), 300);
}
});
// Cancel generation
jQuery('#pw_surprise_cancel_gen').on('click', () => {
if (surpriseAbortController) surpriseAbortController.abort();
});
// OK button
jQuery('#pw_surprise_ok').on('click', () => {
modal.removeClass('active');
setTimeout(() => modal.remove(), 300);
});
// Show modal
setTimeout(() => modal.addClass('active'), 10);
// Start generation
runSurpriseGeneration(category, modal);
}
async function runSurpriseGeneration(category, modal) {
surpriseAbortController = new AbortController();
const signal = surpriseAbortController.signal;
try {
const text = await generateSurpriseText(category, signal);
if (signal.aborted) return;
// Pick how many NEW messages must pass before the surprise triggers
const dMin = Math.max(1, Math.min(12, settings.surprise_depth_min || 2));
const dMax = Math.max(dMin, Math.min(12, settings.surprise_depth_max || 6));
const triggerAfter = settings.surprise_randomize
? Math.floor(Math.random() * (dMax - dMin + 1)) + dMin
: dMin;
// Record current chat length so we can count new messages from here
const stContext = SillyTavern.getContext();
const baseMessageCount = stContext?.chat?.length ?? 0;
const key = `pathweaver_surprise_${surpriseKeyCounter++}`;
activeSurprises.push({ key, category, triggerAfter, baseMessageCount, text, injected: false });
saveSurpriseQueue();
// Update bar and queue display
createActionBar();
renderSurpriseQueue();
// Show done state
jQuery('#pw_surprise_processing').hide();
jQuery('#pw_surprise_done').show();
} catch (err) {
if (err.name === 'AbortError' || signal.aborted) {
// User cancelled — just close
if (modal && modal.length) {
modal.removeClass('active');
setTimeout(() => modal.remove(), 300);
}
return;
}
error('Surprise generation failed:', err);
jQuery('#pw_surprise_processing').hide();
jQuery('#pw_surprise_error_msg').text(err.message || 'Generation failed. Please try again.');
jQuery('#pw_surprise_error').show();
// Retry button
jQuery('#pw_surprise_retry').off('click').on('click', () => {
jQuery('#pw_surprise_error').hide();
jQuery('#pw_surprise_processing').show();
runSurpriseGeneration(category, modal);
});
} finally {
surpriseAbortController = null;
}
}
// ============================================================
// SETTINGS PANEL (for ST extension panel)
// ============================================================
async function initSettingsPanel() {
try {
const response = await fetch(`${BASE_URL}/settings.html`);
if (response.ok) {
const html = await response.text();
jQuery('#extensions_settings').append(html);
log('Settings panel loaded');
// Bind event handlers for settings.html
bindSettingsPanelEvents();
// Apply current settings to UI
applySettingsToUI();
}
} catch (err) {
warn('Failed to load settings panel:', err);
}
}
function applySettingsToUI() {
// Delegate all field-restoration to syncSettingsToPanel, which is the
// single authoritative place that maps every settings key to its panel
// element. Previously applySettingsToUI was a hand-maintained partial
// copy of that function and had drifted out of sync — causing several
// settings (#pw_openai_key, #pw_stream_suggestions, #pw_bar_title_font,
// #pw_surprise_randomize, #pw_surprise_depth_min/_max, and the
// randomize show/hide block) to always revert to their HTML defaults
// after every page reload even though the values were saved correctly.
syncSettingsToPanel();
// These extras are init-only (dynamic data that syncSettingsToPanel
// intentionally omits because they require async work or DOM queries
// that are only meaningful on first load):
populateProfileDropdown('#pw_profile_select');
if (settings.source === 'ollama') {
fetchAndPopulateOllamaModels('#pw_ollama_model');
}
}
function updateProviderVisibility(source) {
jQuery('#pw_profile_settings, #pw_ollama_settings, #pw_openai_settings').hide();
if (source === 'profile') jQuery('#pw_profile_settings').show();
else if (source === 'ollama') jQuery('#pw_ollama_settings').show();
else if (source === 'openai') jQuery('#pw_openai_settings').show();
}
function populateProfileDropdown(selector) {
const select = jQuery(selector);
if (!select.length) return;
select.empty();
select.append('<option value="">-- Select Profile --</option>');
const profiles = getConnectionProfiles();
profiles.forEach(p => {
const selected = settings.preset === p.name ? ' selected' : '';
const safeName = escapeHtmlAttr(p.name);
select.append(`<option value="${safeName}"${selected}>${safeName}</option>`);
});
}
async function fetchAndPopulateOllamaModels(selector) {
const select = jQuery(selector);
if (!select.length) return;
select.html('<option value="">Loading...</option>');
const models = await fetchOllamaModels();
select.empty();
if (models.length) {
models.forEach(m => {
const selected = settings.ollama_model === m.name ? ' selected' : '';
select.append(`<option value="${m.name}"${selected}>${m.name}</option>`);
});
// Auto-select first if none selected
if (!settings.ollama_model && models.length) {
settings.ollama_model = models[0].name;
select.val(settings.ollama_model);
saveSettings();
}
} else {
select.append('<option value="">No models found</option>');
}
}
// Sync settings from modal to extension panel
function syncSettingsToPanel() {
jQuery('#pw_enabled').prop('checked', settings.enabled);
jQuery('#pw_source').val(settings.source);
jQuery('#pw_profile_select').val(settings.preset);
jQuery('#pw_ollama_url').val(settings.ollama_url);
jQuery('#pw_ollama_model').val(settings.ollama_model);
jQuery('#pw_openai_preset').val(settings.openai_preset);
jQuery('#pw_openai_url').val(settings.openai_url);
jQuery('#pw_openai_model').val(settings.openai_model);
jQuery('#pw_openai_key').val(settings.openai_key);
jQuery('#pw_suggestions_count').val(settings.suggestions_count);
jQuery('#pw_context_depth').val(settings.context_depth);
jQuery('#pw_suggestion_length').val(settings.suggestion_length);
jQuery('#pw_insert_mode').prop('checked', settings.insert_mode);
jQuery('#pw_show_explicit').prop('checked', settings.show_explicit);
jQuery('#pw_hide_animated_bar').prop('checked', settings.hide_animated_bar);
jQuery('#pw_insert_type_enabled').prop('checked', settings.insert_type_enabled);
jQuery('#pw_insert_type_ooc').prop('checked', settings.insert_type_ooc);
jQuery('#pw_insert_type_director').prop('checked', settings.insert_type_director);
if (settings.insert_type_enabled) jQuery('#pw_insert_type_options').css('display', 'flex');
else jQuery('#pw_insert_type_options').hide();
jQuery('#pw_font_size').val(settings.bar_font_size);
jQuery('#pw_bar_height').val(settings.bar_height);
jQuery('#pw_bar_title_font').val(settings.bar_title_font || 'default');
applyTitleFontSelectDisplay(document.getElementById('pw_bar_title_font'));
// Context sources
jQuery('#pw_include_scenario').prop('checked', settings.include_scenario);
jQuery('#pw_include_description').prop('checked', settings.include_description);
jQuery('#pw_include_worldinfo').prop('checked', settings.include_worldinfo);
jQuery('#pw_stream_suggestions').prop('checked', settings.stream_suggestions);
// Reasoning mode settings
jQuery('#pw_reasoning_mode').prop('checked', settings.reasoning_mode);
jQuery('#pw_max_output_tokens').val(settings.max_output_tokens || 16384);
if (settings.reasoning_mode) {
jQuery('#pw_max_output_tokens_row').show();
} else {
jQuery('#pw_max_output_tokens_row').hide();
}
// Surprise Me
jQuery('#pw_surprise_randomize').prop('checked', settings.surprise_randomize);
jQuery('#pw_surprise_endless').prop('checked', settings.surprise_endless);
jQuery('#pw_surprise_depth_min').val(settings.surprise_depth_min);
jQuery('#pw_surprise_depth_max').val(settings.surprise_depth_max);
if (settings.surprise_randomize) {
jQuery('#pw_surprise_range_rows').show();
jQuery('#pw_surprise_fixed_hint').hide();
} else {
jQuery('#pw_surprise_range_rows').hide();
jQuery('#pw_surprise_fixed_hint').show();
jQuery('#pw_surprise_fixed_depth_label').text(settings.surprise_depth_min);
}
updateProviderVisibility(settings.source);
}
// Sync settings from extension panel to modal (if open)
function syncSettingsToModal() {
jQuery('#pw_sm_source').val(settings.source);
jQuery('#pw_sm_profile').val(settings.preset);
jQuery('#pw_sm_ollama_url').val(settings.ollama_url);
jQuery('#pw_sm_ollama_model').val(settings.ollama_model);
jQuery('#pw_sm_openai_url').val(settings.openai_url);
jQuery('#pw_sm_openai_model').val(settings.openai_model);
jQuery('#pw_sm_openai_key').val(settings.openai_key);
jQuery('#pw_sm_suggestions').val(settings.suggestions_count);
jQuery('#pw_sm_context').val(settings.context_depth);
jQuery('#pw_sm_suggestion_length').val(settings.suggestion_length);
jQuery('.pw_toggle[data-setting="stream_suggestions"]').toggleClass('active', settings.stream_suggestions);
jQuery('#pw_sm_font_size').val(settings.bar_font_size);
jQuery('#pw_sm_bar_height').val(settings.bar_height);
jQuery('#pw_sm_bar_title_font').val(settings.bar_title_font || 'default');
applyTitleFontSelectDisplay(document.getElementById('pw_sm_bar_title_font'));
// Update toggles in modal
jQuery('.pw_toggle[data-setting="enabled"]').toggleClass('active', settings.enabled);
jQuery('.pw_toggle[data-setting="show_explicit"]').toggleClass('active', settings.show_explicit);
jQuery('.pw_toggle[data-setting="insert_mode"]').toggleClass('active', settings.insert_mode);
jQuery('.pw_toggle[data-setting="hide_animated_bar"]').toggleClass('active', settings.hide_animated_bar);
jQuery('.pw_toggle[data-setting="insert_type_enabled"]').toggleClass('active', settings.insert_type_enabled);
if (settings.insert_type_enabled) jQuery('#pw_sm_insert_type_options').css('display', 'flex');
else jQuery('#pw_sm_insert_type_options').hide();
jQuery('.pw_toggle[data-setting="insert_type_ooc"]').toggleClass('active', settings.insert_type_ooc);
jQuery('.pw_toggle[data-setting="insert_type_director"]').toggleClass('active', settings.insert_type_director);
// Surprise Me
jQuery('.pw_toggle[data-setting="surprise_randomize"]').toggleClass('active', settings.surprise_randomize);
jQuery('.pw_toggle[data-setting="surprise_endless"]').toggleClass('active', settings.surprise_endless);
jQuery('#pw_sm_surprise_depth_min').val(settings.surprise_depth_min);
jQuery('#pw_sm_surprise_depth_max').val(settings.surprise_depth_max);
if (settings.surprise_randomize) {
jQuery('#pw_sm_surprise_range_rows').show();
jQuery('#pw_sm_surprise_fixed_hint').hide();
} else {
jQuery('#pw_sm_surprise_range_rows').hide();
jQuery('#pw_sm_surprise_fixed_hint').show();
jQuery('#pw_sm_surprise_fixed_hint p strong').text(settings.surprise_depth_min);
}
// Context sources toggles
jQuery('.pw_toggle[data-setting="include_scenario"]').toggleClass('active', settings.include_scenario);
jQuery('.pw_toggle[data-setting="include_description"]').toggleClass('active', settings.include_description);
jQuery('.pw_toggle[data-setting="include_worldinfo"]').toggleClass('active', settings.include_worldinfo);
// Reasoning mode settings
jQuery('.pw_toggle[data-setting="reasoning_mode"]').toggleClass('active', settings.reasoning_mode);
jQuery('#pw_max_output_tokens').val(settings.max_output_tokens || 16384);
if (settings.reasoning_mode) {
jQuery('#pw_max_output_tokens_row').show();
} else {
jQuery('#pw_max_output_tokens_row').hide();
}
// Update provider visibility
jQuery('#pw_sm_profile_box, #pw_sm_ollama_box, #pw_sm_openai_box').hide();
if (settings.source === 'profile') jQuery('#pw_sm_profile_box').show();
else if (settings.source === 'ollama') jQuery('#pw_sm_ollama_box').show();
else if (settings.source === 'openai') jQuery('#pw_sm_openai_box').show();
renderSurpriseQueue();
}
function bindSettingsPanelEvents() {
// Enable toggle
jQuery('#pw_enabled').on('change', function () {
settings.enabled = this.checked;
saveSettings();
syncSettingsToModal();
createActionBar();
});
// Source dropdown - in settings panel
jQuery('#pw_source').on('change', function () {
settings.source = this.value;
saveSettings();
updateProviderVisibility(this.value);
syncSettingsToModal();
if (this.value === 'ollama') {
fetchAndPopulateOllamaModels('#pw_ollama_model');
}
});
// Profile select
jQuery('#pw_profile_select').on('change', function () {
settings.preset = this.value;
saveSettings();
syncSettingsToModal();
});
// Ollama URL
jQuery('#pw_ollama_url').on('change', function () {
settings.ollama_url = this.value;
saveSettings();
syncSettingsToModal();
fetchAndPopulateOllamaModels('#pw_ollama_model');
});
// Ollama model
jQuery('#pw_ollama_model').on('change', function () {
settings.ollama_model = this.value;
saveSettings();
syncSettingsToModal();
});
// OpenAI preset
jQuery('#pw_openai_preset').on('change', function () {
settings.openai_preset = this.value;
const presets = {
lmstudio: { url: 'http://localhost:1234/v1', model: 'local-model' },
kobold: { url: 'http://localhost:5001/v1', model: 'koboldcpp' },
textgen: { url: 'http://localhost:5000/v1', model: 'local-model' },
vllm: { url: 'http://localhost:8000/v1', model: 'local-model' }
};
if (presets[this.value]) {
settings.openai_url = presets[this.value].url;
settings.openai_model = presets[this.value].model;
jQuery('#pw_openai_url').val(settings.openai_url);
jQuery('#pw_openai_model').val(settings.openai_model);
}
saveSettings();
});
// OpenAI URL
jQuery('#pw_openai_url').on('change', function () {
settings.openai_url = this.value;
saveSettings();
syncSettingsToModal();
});
// OpenAI model
jQuery('#pw_openai_model').on('change', function () {
settings.openai_model = this.value;
saveSettings();
syncSettingsToModal();
});
// OpenAI Key
jQuery('#pw_openai_key').on('change', function () {
settings.openai_key = this.value;
saveSettings();
syncSettingsToModal();
});
// Suggestions count
jQuery('#pw_suggestions_count').on('change', function () {
settings.suggestions_count = Math.max(1, Math.min(20, parseInt(this.value) || 10));
this.value = settings.suggestions_count;
saveSettings();
syncSettingsToModal();
});
// Context depth
jQuery('#pw_context_depth').on('change', function () {
settings.context_depth = parseInt(this.value) || 4;
saveSettings();
syncSettingsToModal();
});
// Font size
jQuery('#pw_font_size').on('change', function () {
settings.bar_font_size = this.value;
saveSettings();
syncSettingsToModal();
createActionBar();
});
// Bar height
jQuery('#pw_bar_height').on('change', function () {
settings.bar_height = this.value;
saveSettings();
syncSettingsToModal();
createActionBar();
});
// Bar title font
jQuery('#pw_bar_title_font').on('change', function () {
settings.bar_title_font = this.value;
applyTitleFontSelectDisplay(this);
saveSettings();
syncSettingsToModal();
createActionBar();
});
// Insert mode
jQuery('#pw_insert_mode').on('change', function () {
settings.insert_mode = this.checked;
saveSettings();
syncSettingsToModal();
});
// Show explicit
jQuery('#pw_show_explicit').on('change', function () {
settings.show_explicit = this.checked;
saveSettings();
syncSettingsToModal();
createActionBar();
});
// Hide Animated Bar
jQuery('#pw_hide_animated_bar').on('change', function () {
settings.hide_animated_bar = this.checked;
saveSettings();
syncSettingsToModal();
jQuery('.pw_action_bar').toggleClass('pw_hide_animated_bar', settings.hide_animated_bar);
});
// Insert Type Enabled
jQuery('#pw_insert_type_enabled').on('change', function () {
settings.insert_type_enabled = this.checked;
saveSettings();
syncSettingsToModal();
if (this.checked) jQuery('#pw_insert_type_options').css('display', 'flex');
else jQuery('#pw_insert_type_options').hide();
});
// Insert Type OOC
jQuery('#pw_insert_type_ooc').on('change', function () {
settings.insert_type_ooc = this.checked;
if (this.checked) {
settings.insert_type_director = false;
jQuery('#pw_insert_type_director').prop('checked', false);
}
saveSettings();
syncSettingsToModal();
});
// Insert Type Director
jQuery('#pw_insert_type_director').on('change', function () {
settings.insert_type_director = this.checked;
if (this.checked) {
settings.insert_type_ooc = false;
jQuery('#pw_insert_type_ooc').prop('checked', false);
}
saveSettings();
syncSettingsToModal();
});
// Suggestion length
jQuery('#pw_suggestion_length').on('change', function () {
settings.suggestion_length = this.value;
saveSettings();
syncSettingsToModal();
});
// Stream suggestions
jQuery('#pw_stream_suggestions').on('change', function () {
settings.stream_suggestions = this.checked;
saveSettings();
syncSettingsToModal();
});
// Reasoning mode toggle
jQuery('#pw_reasoning_mode').on('change', function () {
settings.reasoning_mode = this.checked;
saveSettings();
syncSettingsToModal();
// Show/hide max_output_tokens setting based on reasoning mode
if (this.checked) {
jQuery('#pw_max_output_tokens_row').show();
} else {
jQuery('#pw_max_output_tokens_row').hide();
}
});
// Max output tokens
jQuery('#pw_max_output_tokens').on('change', function () {
const val = parseInt(this.value) || 16384;
settings.max_output_tokens = Math.max(512, Math.min(128000, val));
this.value = settings.max_output_tokens;
saveSettings();
syncSettingsToModal();
});
// Include Scenario
jQuery('#pw_include_scenario').on('change', function () {
settings.include_scenario = this.checked;
saveSettings();
syncSettingsToModal();
});
// Include Description
jQuery('#pw_include_description').on('change', function () {
settings.include_description = this.checked;
saveSettings();
syncSettingsToModal();
});
// Include World Info
jQuery('#pw_include_worldinfo').on('change', function () {
settings.include_worldinfo = this.checked;
saveSettings();
syncSettingsToModal();
});
// Open Style Editor from settings
jQuery('#pw_open_editor_settings').on('click', function () {
openStyleEditor();
});
// Surprise Me: endless toggle
jQuery('#pw_surprise_endless').on('change', function () {
settings.surprise_endless = this.checked;
saveSettings();
syncSettingsToModal();
if (this.checked) {
showToast('Endless Surprises enabled — surprises will keep arming themselves!');
} else {
// Clear any pending surprises so no background generation fires
// after the user turns off the feature.
clearAllSurprises();
renderSurpriseQueue();
createActionBar();
showToast('Endless Surprises disabled — surprise queue cleared.');
}
});
// Surprise Me: randomize toggle
jQuery('#pw_surprise_randomize').on('change', function () { settings.surprise_randomize = this.checked;
if (settings.surprise_randomize) {
jQuery('#pw_surprise_range_rows').show();
jQuery('#pw_surprise_fixed_hint').hide();
} else {
jQuery('#pw_surprise_range_rows').hide();
jQuery('#pw_surprise_fixed_hint').show();
jQuery('#pw_surprise_fixed_depth_label').text(settings.surprise_depth_min);
}
saveSettings();
syncSettingsToModal();
});
// Surprise Me: depth min select
jQuery('#pw_surprise_depth_min').on('change', function () {
const val = parseInt(this.value) || 2;
settings.surprise_depth_min = val;
if (settings.surprise_depth_max < val) {
settings.surprise_depth_max = val;
jQuery('#pw_surprise_depth_max').val(val);
}
saveSettings();
syncSettingsToModal();
});
// Surprise Me: depth max select
jQuery('#pw_surprise_depth_max').on('change', function () {
const val = parseInt(this.value) || 6;
settings.surprise_depth_max = val;
if (settings.surprise_depth_min > val) {
settings.surprise_depth_min = val;
jQuery('#pw_surprise_depth_min').val(val);
}
saveSettings();
syncSettingsToModal();
});
// Surprise Me: clear all (settings panel)
jQuery('#pw_surprise_clear_all').on('click', function () {
clearAllSurprises();
renderSurpriseQueue();
createActionBar();
showToast('Surprises cleared!');
});
// Surprise Me: remove individual item from panel list
jQuery('#pw_surprise_queue_list').on('click', '.pw_sq_remove', function () {
const idx = parseInt(jQuery(this).data('surprise-index'));
if (!isNaN(idx) && idx >= 0 && idx < activeSurprises.length) {
const s = activeSurprises[idx];
if (s.injected) clearSurprisePrompt(s.key);
activeSurprises.splice(idx, 1);
saveSurpriseQueue();
renderSurpriseQueue();
createActionBar();
showToast('Surprise removed.');
}
});
createActionBar();
}
// ============================================================
// EVENT HANDLERS
// ============================================================
// Named handlers for cleanup
const handleChatChanged = () => {
cachedSuggestions = {};
cachedChatId = null;
for (const s of activeSurprises) {
if (s.injected) clearSurprisePrompt(s.key);
}
activeSurprises = [];
loadSurpriseQueue();
renderSurpriseQueue();
createActionBar();
};
const handleSettingsUpdated = () => {
populateConnectionProfiles();
};
const handleMessageSent = () => {
jQuery('.pw_action_bar').addClass('pw_processing');
};
const handleGenerationEnded = () => {
jQuery('.pw_action_bar').removeClass('pw_processing');
cachedSuggestions = {};
checkSurpriseTrigger();
};
function checkSurpriseTrigger() {
if (!activeSurprises.length) return;
const stContext = SillyTavern.getContext();
const currentCount = stContext?.chat?.length ?? 0;
let changed = false;
for (let i = activeSurprises.length - 1; i >= 0; i--) {
const s = activeSurprises[i];
const newMessages = currentCount - s.baseMessageCount;
if (s.injected) {
clearSurprisePrompt(s.key);
const firedCategory = s.category; // capture before splice
activeSurprises.splice(i, 1);
changed = true;
log('Surprise', s.key, 'cleared after firing');
// Endless Surprises: silently queue a fresh one with the same style
if (settings.surprise_endless) {
scheduleEndlessSurprise(firedCategory);
}
} else if (newMessages >= s.triggerAfter) {
const ok = injectSurprisePrompt(s.text, 1, s.key);
if (ok) {
s.injected = true;
changed = true;
log('Surprise', s.key, 'triggered after', newMessages, 'new messages');
}
}
}
if (changed) {
saveSurpriseQueue();
renderSurpriseQueue();
createActionBar();
}
}
/**
* Silently generate and arm a new surprise for the given category.
* Used by Endless Surprises to keep the queue perpetually loaded
* without interrupting the user.
* A 4-second delay is intentional — it lets KoboldCPP (and other local
* backends) fully release CUDA/GPU resources before a second request
* arrives, preventing concurrent-request crashes.
*/
async function scheduleEndlessSurprise(category) {
log('Endless Surprises: scheduling background surprise for category', category);
// Guard: never fire a background generation while the main pipeline is active.
if (isGenerating) {
log('Endless Surprises: skipping — main generation in progress');
return;
}
try {
// Delay before re-arming so local backends (KoboldCPP, Ollama, etc.)
// have time to fully release GPU/CUDA resources after the last response.
await new Promise(resolve => setTimeout(resolve, 4000));
// Re-check after the delay in case a new generation started during the wait.
if (isGenerating) {
log('Endless Surprises: skipping after delay — main generation started');
return;
}
const endlessAbort = new AbortController();
const text = await generateSurpriseText(category, endlessAbort.signal);
const dMin = Math.max(1, Math.min(12, settings.surprise_depth_min || 2));
const dMax = Math.max(dMin, Math.min(12, settings.surprise_depth_max || 6));
const triggerAfter = settings.surprise_randomize
? Math.floor(Math.random() * (dMax - dMin + 1)) + dMin
: dMin;
const stContext = SillyTavern.getContext();
const baseMessageCount = stContext?.chat?.length ?? 0;
const key = `pathweaver_surprise_${surpriseKeyCounter++}`;
activeSurprises.push({ key, category, triggerAfter, baseMessageCount, text, injected: false });
saveSurpriseQueue();
renderSurpriseQueue();
createActionBar();
log('Endless Surprises: new surprise armed for category', category, '— fires in', triggerAfter, 'messages');
} catch (err) {
if (err.name === 'AbortError') return;
warn('Endless Surprises: background generation failed:', err.message);
}
}
const handleProfileMousedown = () => {
populateConnectionProfiles();
};
function registerEvents() {
const { eventSource, event_types } = SillyTavern.getContext();
// Document events - Namespace them!
jQuery(document).on('mousedown.pathweaver', '#pw_profile_select, #pw_sm_profile', handleProfileMousedown);
// EventSource events
eventSource.on(event_types.CHAT_CHANGED, handleChatChanged);
eventSource.on(event_types.SETTINGS_UPDATED, handleSettingsUpdated);
eventSource.on(event_types.MESSAGE_SENT, handleMessageSent);
eventSource.on(event_types.GENERATION_ENDED, handleGenerationEnded);
}
// Expose cleanup function for hot reload
window.pathweaver_cleanup = function () {
if (DEBUG) console.log(`[${EXTENSION_NAME}] Cleaning up...`);
const { eventSource, event_types } = SillyTavern.getContext();
// Remove EventSource listeners
eventSource.removeListener(event_types.CHAT_CHANGED, handleChatChanged);
eventSource.removeListener(event_types.SETTINGS_UPDATED, handleSettingsUpdated);
eventSource.removeListener(event_types.MESSAGE_SENT, handleMessageSent);
eventSource.removeListener(event_types.GENERATION_ENDED, handleGenerationEnded);
// Remove Document listeners
jQuery(document).off('mousedown.pathweaver');
jQuery(document).off('keydown.pathweaver_suggestions');
jQuery(document).off('click.pw_dropdown_close');
jQuery(document).off('click.pw_icon_dd');
jQuery(document).off('click.pw_icon_dd_outside');
jQuery(document).off('input.pw_icon_search');
jQuery(document).off('click.pw_icon_grid');
jQuery('body').removeClass('pw_icon_dd_open');
jQuery('body').removeClass('pw_icon_dd_open');
// Remove UI elements
jQuery('.pw_action_bar').remove();
jQuery('#pw_suggestions_modal').remove();
jQuery('#pw_settings_modal').remove();
jQuery('#pw_styles_manager').remove();
jQuery('#pw_director_modal').remove();
jQuery('#pw_surprise_modal').remove();
// Clear any active surprise injections (leave chatMetadata intact for restore on next init)
try {
for (const s of activeSurprises) {
if (s.injected) clearSurprisePrompt(s.key);
}
} catch (_) { }
if (surpriseAbortController) { try { surpriseAbortController.abort(); } catch (_) { } }
// Reset state
actionBar = null;
suggestionsModal = null;
settingsModal = null;
activeSurprises = [];
surpriseKeyCounter = 0;
surpriseAbortController = null;
};
// ============================================================
// INITIALIZATION (Pattern from EchoChamber)
// ============================================================
async function init() {
log('Initializing...');
// Wait for SillyTavern to be ready
if (typeof SillyTavern === 'undefined' || !SillyTavern.getContext) {
warn('SillyTavern not ready, retrying in 500ms...');
setTimeout(init, 500);
return;
}
const stContext = SillyTavern.getContext();
log('Context available:', !!stContext);
try {
loadSettings();
await initSettingsPanel();
loadSurpriseQueue();
renderSurpriseQueue();
createActionBar();
registerEvents();
log('Initialized successfully');
} catch (err) {
error('Initialization failed:', err);
}
}
// Start when DOM is ready (like EchoChamber)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
// TEST APPEND