mirror of
https://github.com/mattjaybe/SillyTavern-Pathweaver.git
synced 2026-04-26 10:30:47 +00:00
- New setting: Reasoning Mode. Increases max tokens and properly handles thinking tags. - Reasoning Mode allows for setting the max output token
4669 lines
221 KiB
JavaScript
4669 lines
221 KiB
JavaScript
// 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, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
|
||
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">×</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">×</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">×</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">×</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">×</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
|