dedfead59e
Emits 'rpg_companion_update_complete' event after updateRPGData() finishes. This allows other extensions (like Context Prewarm) to hook into the completion of tracker updates and perform actions afterward. The event is emitted in the finally block, so it fires regardless of success or failure, after isGenerating is reset.
432 lines
18 KiB
JavaScript
432 lines
18 KiB
JavaScript
/**
|
|
* API Client Module
|
|
* Handles API calls for RPG tracker generation
|
|
*/
|
|
|
|
import { generateRaw, chat, eventSource } from '../../../../../../../script.js';
|
|
import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.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
|
|
} from '../../core/state.js';
|
|
import { saveChatData } 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';
|
|
|
|
// 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<string>} 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();
|
|
|
|
if (!data.choices || !data.choices[0] || !data.choices[0].message) {
|
|
throw new Error('Invalid response format from external API');
|
|
}
|
|
|
|
const content = data.choices[0].message.content;
|
|
// 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<string|null>} 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<boolean>} 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) {
|
|
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);
|
|
|
|
// Update button to show "Updating..." state
|
|
const $updateBtn = $('#rpg-manual-update');
|
|
const updatingText = i18n.getTranslation('template.mainPanel.updating') || 'Updating...';
|
|
$updateBtn.html(`<i class="fa-solid fa-spinner fa-spin"></i> ${updatingText}`).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
|
|
response = await generateRaw({
|
|
prompt: prompt,
|
|
quietToLoud: false
|
|
});
|
|
}
|
|
|
|
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'), '', { 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;
|
|
}
|
|
|
|
// When saveTrackerHistory is enabled, store tracker data on the user's message too
|
|
// This allows scrolling through history and seeing trackers at each point
|
|
if (extensionSettings.saveTrackerHistory && lastMessage && lastMessage.is_user) {
|
|
if (!lastMessage.extra) {
|
|
lastMessage.extra = {};
|
|
}
|
|
lastMessage.extra.rpg_companion_data = {
|
|
userStats: parsedData.userStats,
|
|
infoBox: parsedData.infoBox,
|
|
characterThoughts: parsedData.characterThoughts,
|
|
timestamp: Date.now()
|
|
};
|
|
// console.log('[RPG Companion] 💾 Stored tracker data on user message for history');
|
|
}
|
|
|
|
// 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;
|
|
lastMessage.extra.rpg_companion_swipes[currentSwipeId] = {
|
|
userStats: parsedData.userStats,
|
|
infoBox: parsedData.infoBox,
|
|
characterThoughts: parsedData.characterThoughts
|
|
};
|
|
|
|
// 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);
|
|
|
|
// Restore button to original state
|
|
const $updateBtn = $('#rpg-manual-update');
|
|
const refreshText = i18n.getTranslation('template.mainPanel.refreshRpgInfo') || 'Refresh RPG Info';
|
|
$updateBtn.html(`<i class="fa-solid fa-sync"></i> ${refreshText}`).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<string>} Array of character names found
|
|
*/
|
|
function parseCharactersFromThoughts(characterThoughtsData) {
|
|
if (!characterThoughtsData) return [];
|
|
|
|
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;
|
|
}
|