/** * API Client Module * Handles API calls for RPG tracker generation */ import { chat, eventSource } from '../../../../../../../script.js'; import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js'; import { safeGenerateRaw, extractTextFromResponse } from '../../utils/responseExtractor.js'; // Custom event name for when RPG Companion finishes updating tracker data // Other extensions can listen for this event to know when RPG Companion is done export const RPG_COMPANION_UPDATE_COMPLETE = 'rpg_companion_update_complete'; import { extensionSettings, lastGeneratedData, committedTrackerData, isGenerating, lastActionWasSwipe, setIsGenerating, setLastActionWasSwipe, $musicPlayerContainer, getSeparateGenerationId } from '../../core/state.js'; import { saveChatData, mirrorToSwipeInfo } from '../../core/persistence.js'; import { generateSeparateUpdatePrompt } from './promptBuilder.js'; import { parseResponse, parseUserStats } from './parser.js'; import { parseAndStoreSpotifyUrl } from '../features/musicPlayer.js'; import { renderUserStats } from '../rendering/userStats.js'; import { renderInfoBox } from '../rendering/infoBox.js'; import { removeLocks } from './lockManager.js'; import { renderThoughts } from '../rendering/thoughts.js'; import { renderInventory } from '../rendering/inventory.js'; import { renderQuests } from '../rendering/quests.js'; import { renderMusicPlayer } from '../rendering/musicPlayer.js'; import { i18n } from '../../core/i18n.js'; import { generateAvatarsForCharacters } from '../features/avatarGenerator.js'; import { setFabLoadingState, updateFabWidgets } from '../ui/mobile.js'; import { updateStripWidgets } from '../ui/desktop.js'; // Store the original preset name to restore after tracker generation let originalPresetName = null; /** * Generates tracker data using an external OpenAI-compatible API. * Used when generationMode is 'external'. * * @param {Array<{role: string, content: string}>} messages - Array of message objects for the API * @returns {Promise} The generated response content * @throws {Error} If the API call fails or configuration is invalid */ export async function generateWithExternalAPI(messages) { const { baseUrl, model, maxTokens, temperature } = extensionSettings.externalApiSettings || {}; // Retrieve API key from secure storage (not shared extension settings) const apiKey = localStorage.getItem('rpg_companion_external_api_key'); // Validate required settings if (!baseUrl || !baseUrl.trim()) { throw new Error('External API base URL is not configured'); } if (!model || !model.trim()) { throw new Error('External API model is not configured'); } // Normalize base URL (remove trailing slash if present) const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, ''); const endpoint = `${normalizedBaseUrl}/chat/completions`; // console.log(`[RPG Companion] Calling external API: ${normalizedBaseUrl} with model: ${model}`); // Prepare headers - only include Authorization if API key is provided const headers = { 'Content-Type': 'application/json' }; if (apiKey && apiKey.trim()) { headers['Authorization'] = `Bearer ${apiKey.trim()}`; } try { const response = await fetch(endpoint, { method: 'POST', headers: headers, body: JSON.stringify({ model: model.trim(), messages: messages, max_tokens: maxTokens || 2048, temperature: temperature ?? 0.7 }) }); if (!response.ok) { const errorText = await response.text(); let errorMessage = `External API error: ${response.status} ${response.statusText}`; try { const errorJson = JSON.parse(errorText); if (errorJson.error?.message) { errorMessage = `External API error: ${errorJson.error.message}`; } } catch (e) { // If parsing fails, use the raw text if it's short enough if (errorText.length < 200) { errorMessage = `External API error: ${errorText}`; } } throw new Error(errorMessage); } const data = await response.json(); const content = extractTextFromResponse(data); if (!content || !content.trim()) { throw new Error('Invalid response format from external API — no text content found'); } // console.log('[RPG Companion] External API response received successfully'); return content; } catch (error) { if (error.name === 'TypeError' && (error.message.includes('fetch') || error.message.includes('Failed to fetch') || error.message.includes('NetworkError'))) { throw new Error(`CORS Access Blocked: This API endpoint (${normalizedBaseUrl}) does not allow direct access from a browser. This is a browser security restriction (CORS), not a bug in the extension. Please use an endpoint that supports CORS (like OpenRouter or a local proxy) or use SillyTavern's internal API system (Separate Mode).`); } throw error; } } /** * Tests the external API connection with a simple request. * @returns {Promise<{success: boolean, message: string, model?: string}>} */ export async function testExternalAPIConnection() { const { baseUrl, model } = extensionSettings.externalApiSettings || {}; const apiKey = localStorage.getItem('rpg_companion_external_api_key'); if (!baseUrl || !model) { return { success: false, message: 'Please fill in all required fields (Base URL and Model). API Key is optional for local servers.' }; } try { const testMessages = [ { role: 'user', content: 'Respond with exactly: "Connection successful"' } ]; const response = await generateWithExternalAPI(testMessages); return { success: true, message: `Connection successful! Model: ${model}`, model: model }; } catch (error) { return { success: false, message: error.message || 'Connection failed' }; } } /** * Gets the current preset name using the /preset command * @returns {Promise} Current preset name or null if unavailable */ export async function getCurrentPresetName() { try { // Use /preset without arguments to get the current preset name const result = await executeSlashCommandsOnChatInput('/preset', { quiet: true }); // console.log('[RPG Companion] /preset result:', result); // The result should be an object with a 'pipe' property containing the preset name if (result && typeof result === 'object' && result.pipe) { const presetName = String(result.pipe).trim(); // console.log('[RPG Companion] Extracted preset name:', presetName); return presetName || null; } // Fallback if result is a string if (typeof result === 'string') { return result.trim() || null; } return null; } catch (error) { console.error('[RPG Companion] Error getting current preset:', error); return null; } } /** * Switches to a specific preset by name using the /preset slash command * @param {string} presetName - Name of the preset to switch to * @returns {Promise} True if switching succeeded, false otherwise */ export async function switchToPreset(presetName) { try { // Use the /preset slash command to switch presets // This is the proper way to change presets in SillyTavern await executeSlashCommandsOnChatInput(`/preset ${presetName}`, { quiet: true }); // console.log(`[RPG Companion] Switched to preset "${presetName}"`); return true; } catch (error) { console.error('[RPG Companion] Error switching preset:', error); return false; } } /** * Updates RPG tracker data using separate API call (separate mode only). * Makes a dedicated API call to generate tracker data, then stores it * in the last assistant message's swipe data. * * @param {Function} renderUserStats - UI function to render user stats * @param {Function} renderInfoBox - UI function to render info box * @param {Function} renderThoughts - UI function to render character thoughts * @param {Function} renderInventory - UI function to render inventory */ export async function updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory, generationId = null) { if (isGenerating) { // console.log('[RPG Companion] Already generating, skipping...'); return; } if (!extensionSettings.enabled) { return; } if (extensionSettings.generationMode !== 'separate' && extensionSettings.generationMode !== 'external') { // console.log('[RPG Companion] Not in separate or external mode, skipping manual update'); return; } const isExternalMode = extensionSettings.generationMode === 'external'; try { setIsGenerating(true); setFabLoadingState(true); // Show spinning FAB on mobile // Update button to show "Updating..." state const $updateBtn = $('#rpg-manual-update'); const $stripRefreshBtn = $('#rpg-strip-refresh'); const updatingText = i18n.getTranslation('template.mainPanel.updating') || 'Updating...'; $updateBtn.html(` ${updatingText}`).prop('disabled', true); $stripRefreshBtn.html('').prop('disabled', true); const prompt = await generateSeparateUpdatePrompt(); // Generate response based on mode let response; if (isExternalMode) { // External mode: Use external OpenAI-compatible API directly // console.log('[RPG Companion] Using external API for tracker generation'); response = await generateWithExternalAPI(prompt); } else { // Separate mode: Use SillyTavern's generateRaw (with extended thinking fallback) response = await safeGenerateRaw({ prompt: prompt, quietToLoud: false }); } // If a generationId was provided and the counter has since been incremented // (by a deletion or a newer generation), discard this result entirely. // The finally block still runs to restore button state. if (generationId !== null && getSeparateGenerationId() !== generationId) { // console.log('[RPG Companion] ⚠️ Separate generation result discarded — superseded (genId', generationId, '!= current', getSeparateGenerationId(), ')'); return; } if (response) { // console.log('[RPG Companion] Raw AI response:', response); const parsedData = parseResponse(response); // Check if parsing completely failed (no tracker data found) if (parsedData.parsingFailed) { toastr.error(i18n.getTranslation('errors.parsingError') || 'RPG Companion Trackers parsing error! The model returned incorrect format. Consider switching generation model if this persists.', '', { timeOut: 5000 }); } // Remove locks from parsed data (JSON format only, text format is unaffected) if (parsedData.userStats) { parsedData.userStats = removeLocks(parsedData.userStats); } if (parsedData.infoBox) { parsedData.infoBox = removeLocks(parsedData.infoBox); } if (parsedData.characterThoughts) { parsedData.characterThoughts = removeLocks(parsedData.characterThoughts); } // Parse and store Spotify URL if feature is enabled parseAndStoreSpotifyUrl(response); // console.log('[RPG Companion] Parsed data:', parsedData); // console.log('[RPG Companion] parsedData.userStats:', parsedData.userStats ? parsedData.userStats.substring(0, 100) + '...' : 'null'); // DON'T update lastGeneratedData here - it should only reflect the data // from the assistant message the user replied to, not auto-generated updates // This ensures swipes/regenerations use consistent source data // Store RPG data for the last assistant message (separate mode) const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null; // console.log('[RPG Companion] Last message is_user:', lastMessage ? lastMessage.is_user : 'no message'); // Update lastGeneratedData for display (regardless of message type) if (parsedData.userStats) { lastGeneratedData.userStats = parsedData.userStats; parseUserStats(parsedData.userStats); } if (parsedData.infoBox) { lastGeneratedData.infoBox = parsedData.infoBox; } if (parsedData.characterThoughts) { lastGeneratedData.characterThoughts = parsedData.characterThoughts; } // Also store on assistant message if present (existing behavior) if (lastMessage && !lastMessage.is_user) { if (!lastMessage.extra) { lastMessage.extra = {}; } if (!lastMessage.extra.rpg_companion_swipes) { lastMessage.extra.rpg_companion_swipes = {}; } const currentSwipeId = lastMessage.swipe_id || 0; const swipeEntry = { userStats: parsedData.userStats, infoBox: parsedData.infoBox, characterThoughts: parsedData.characterThoughts }; lastMessage.extra.rpg_companion_swipes[currentSwipeId] = swipeEntry; // Mirror to swipe_info so this swipe survives page reload even if never manually edited mirrorToSwipeInfo(lastMessage, currentSwipeId, swipeEntry); // console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId); } // Only commit on TRULY first generation (no committed data exists at all) // This prevents auto-commit after refresh when we have saved committed data const hasAnyCommittedContent = ( (committedTrackerData.userStats && committedTrackerData.userStats.trim() !== '') || (committedTrackerData.infoBox && committedTrackerData.infoBox.trim() !== '' && committedTrackerData.infoBox !== 'Info Box\n---\n') || (committedTrackerData.characterThoughts && committedTrackerData.characterThoughts.trim() !== '' && committedTrackerData.characterThoughts !== 'Present Characters\n---\n') ); // Only commit if we have NO committed content at all (truly first time ever) if (!hasAnyCommittedContent) { committedTrackerData.userStats = parsedData.userStats; committedTrackerData.infoBox = parsedData.infoBox; committedTrackerData.characterThoughts = parsedData.characterThoughts; // console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data'); } // Render the updated data renderUserStats(); renderInfoBox(); renderThoughts(); renderInventory(); renderQuests(); renderMusicPlayer($musicPlayerContainer[0]); // Save to chat metadata saveChatData(); // Generate avatars if auto-generate is enabled (runs within this workflow) // This uses the RPG Companion Trackers preset and keeps the button spinning if (extensionSettings.autoGenerateAvatars) { const charactersNeedingAvatars = parseCharactersFromThoughts(parsedData.characterThoughts); if (charactersNeedingAvatars.length > 0) { // console.log('[RPG Companion] Generating avatars for:', charactersNeedingAvatars); // Generate avatars - this awaits completion await generateAvatarsForCharacters(charactersNeedingAvatars, (names) => { // Callback when generation starts - re-render to show loading spinners // console.log('[RPG Companion] Avatar generation started, showing spinners...'); renderThoughts(); }); // Re-render once all avatars are generated // console.log('[RPG Companion] All avatars generated, re-rendering...'); renderThoughts(); } } } } catch (error) { console.error('[RPG Companion] Error updating RPG data:', error); if (isExternalMode) { toastr.error(error.message, 'RPG Companion External API Error'); } } finally { setIsGenerating(false); setFabLoadingState(false); // Stop spinning FAB on mobile updateFabWidgets(); // Update FAB widgets with new data updateStripWidgets(); // Update strip widgets with new data // Restore button to original state const $updateBtn = $('#rpg-manual-update'); const $stripRefreshBtn = $('#rpg-strip-refresh'); const refreshText = i18n.getTranslation('template.mainPanel.refreshRpgInfo') || 'Refresh RPG Info'; $updateBtn.html(` ${refreshText}`).prop('disabled', false); $stripRefreshBtn.html('').prop('disabled', false); // Reset the flag after tracker generation completes // This ensures the flag persists through both main generation AND tracker generation // console.log('[RPG Companion] 🔄 Tracker generation complete - resetting lastActionWasSwipe to false'); setLastActionWasSwipe(false); // Emit event for other extensions to know RPG Companion has finished updating console.debug('[RPG Companion] Emitting RPG_COMPANION_UPDATE_COMPLETE event'); eventSource.emit(RPG_COMPANION_UPDATE_COMPLETE); } } /** * Parses character names from Present Characters thoughts data * @param {string} characterThoughtsData - Raw character thoughts data * @returns {Array} Array of character names found */ function parseCharactersFromThoughts(characterThoughtsData) { if (!characterThoughtsData) return []; // Try parsing as JSON first (current format) try { const parsed = typeof characterThoughtsData === 'string' ? JSON.parse(characterThoughtsData) : characterThoughtsData; // Handle both {characters: [...]} and direct array formats const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []); if (charactersArray.length > 0) { // Extract names from JSON character objects return charactersArray .map(char => char.name) .filter(name => name && name.toLowerCase() !== 'unavailable'); } } catch (e) { // Not JSON, fall back to text parsing } // Fallback: Parse text format (legacy) const lines = characterThoughtsData.split('\n'); const characters = []; for (const line of lines) { if (line.trim().startsWith('- ')) { const name = line.trim().substring(2).trim(); if (name && name.toLowerCase() !== 'unavailable') { characters.push(name); } } } return characters; }