Merge pull request #79 from munimunigamer/external-mode

feature: Add External API Generation Mode with Secure Key Storage
This commit is contained in:
Spicy Marinara
2025-12-29 10:29:35 +01:00
committed by GitHub
8 changed files with 571 additions and 116 deletions
+8
View File
@@ -172,6 +172,14 @@ export let extensionSettings = {
// Auto avatar generation settings
autoGenerateAvatars: false, // Master toggle for auto-generating avatars
avatarLLMCustomInstruction: '', // Custom instruction for LLM prompt generation
// External API settings for 'external' generation mode
externalApiSettings: {
baseUrl: '', // OpenAI-compatible API base URL (e.g., "https://api.openai.com/v1")
// apiKey is NOT stored here for security. It is stored in localStorage('rpg_companion_api_key')
model: '', // Model identifier (e.g., "gpt-4o-mini")
maxTokens: 8192, // Maximum tokens for generation
temperature: 0.7 // Temperature setting for generation
}
};
/**
+12 -1
View File
@@ -53,7 +53,18 @@
"template.settingsModal.advanced.generationMode": "Generation Mode:",
"template.settingsModal.advanced.generationModeOptions.together": "Together with Main Generation",
"template.settingsModal.advanced.generationModeOptions.separate": "Separate Generation",
"template.settingsModal.advanced.generationModeNote": "Together: Adds RPG tracking to main roleplay. Separate: Generates RPG data separately (manual or auto).",
"template.settingsModal.advanced.generationModeNote": "Together: Adds RPG tracking to main roleplay. Separate: Generates RPG data separately (manual or auto). External: Connects to an OpenAI-compatible endpoint directly.",
"template.settingsModal.advanced.generationModeOptions.external": "External API",
"template.settingsModal.advanced.externalApi.title": "External API Settings",
"template.settingsModal.advanced.externalApi.baseUrl": "API Base URL",
"template.settingsModal.advanced.externalApi.baseUrlNote": "OpenAI-compatible endpoint (e.g., OpenAI, OpenRouter, local LLM server)",
"template.settingsModal.advanced.externalApi.apiKey": "API Key",
"template.settingsModal.advanced.externalApi.apiKeyNote": "Your API key for the external service",
"template.settingsModal.advanced.externalApi.model": "Model",
"template.settingsModal.advanced.externalApi.modelNote": "Model identifier (e.g., gpt-4o-mini, claude-3-haiku, mistral-7b)",
"template.settingsModal.advanced.externalApi.maxTokens": "Max Tokens",
"template.settingsModal.advanced.externalApi.temperature": "Temperature",
"template.settingsModal.advanced.externalApi.testConnection": "Test Connection",
"template.settingsModal.advanced.contextMessages": "Context Messages:",
"template.settingsModal.advanced.contextMessagesNote": "Number of recent messages to include (Separate mode only)",
"template.settingsModal.advanced.memoryBatchSize": "Memory Batch Size:",
+13 -2
View File
@@ -44,8 +44,19 @@
"template.settingsModal.advanced.generationMode": "生成模式:",
"template.settingsModal.advanced.generationModeOptions.together": "同時生成",
"template.settingsModal.advanced.generationModeOptions.separate": "單獨生成",
"template.settingsModal.advanced.generationModeNote": "同時生成:將 RPG 追蹤添加到主要提示詞中一同生成。單獨生成:分開生成 RPG 數據。(就是手動或自動的差別)。",
"template.settingsModal.advanced.contextMessages": "上下文訊息:",
"template.settingsModal.advanced.generationModeNote": "同時生成:將 RPG 追蹤添加到主要提示詞中一同生成。單獨生成:分開生成 RPG 數據。(就是手動或自動的差別)。外部 API:直接連接 OpenAI 兼容端點生成數據。",
"template.settingsModal.advanced.generationModeOptions.external": "外部 API",
"template.settingsModal.advanced.externalApi.title": "外部 API 設定",
"template.settingsModal.advanced.externalApi.baseUrl": "API 基礎 URL",
"template.settingsModal.advanced.externalApi.baseUrlNote": "OpenAI 兼容端點(例如 OpenAI、OpenRouter、本地 LLM 伺服器)",
"template.settingsModal.advanced.externalApi.apiKey": "API 金鑰",
"template.settingsModal.advanced.externalApi.apiKeyNote": "外部服務的 API 金鑰",
"template.settingsModal.advanced.externalApi.model": "模型",
"template.settingsModal.advanced.externalApi.modelNote": "模型識別碼(例如 gpt-4o-mini、claude-3-haiku、mistral-7b",
"template.settingsModal.advanced.externalApi.maxTokens": "最大 Token",
"template.settingsModal.advanced.externalApi.temperature": "溫度 (Temperature)",
"template.settingsModal.advanced.externalApi.testConnection": "測試連接",
"template.settingsModal.advanced.contextMessages": "上下文訊息:",
"template.settingsModal.advanced.contextMessagesNote": "包含的最近訊息數量(僅限單獨生成模式)",
"template.settingsModal.advanced.memoryBatchSize": "記憶批次大小:",
"template.settingsModal.advanced.memoryBatchSizeNote": "在記憶回憶中每批處理的訊息數量",
+12 -5
View File
@@ -15,7 +15,7 @@ import { selected_group, getGroupMembers } from '../../../../../../group-chats.j
import { extensionSettings, sessionAvatarPrompts, setSessionAvatarPrompt } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { generateAvatarPromptGenerationPrompt, parseAvatarPromptsResponse } from '../generation/promptBuilder.js';
import { getCurrentPresetName, switchToPreset } from '../generation/apiClient.js';
import { getCurrentPresetName, switchToPreset, generateWithExternalAPI } from '../generation/apiClient.js';
// Generation state - tracks characters currently being generated
const pendingGenerations = new Set();
@@ -260,10 +260,17 @@ async function generateLLMPrompts(characterNames) {
console.log('[RPG Avatar] Generating LLM prompts for:', characterNames);
const promptMessages = await generateAvatarPromptGenerationPrompt(characterNames);
const response = await generateRaw({
prompt: promptMessages,
quietToLoud: false
});
let response;
if (extensionSettings.generationMode === 'external') {
console.log('[RPG Avatar] Using external API for avatar prompt generation');
response = await generateWithExternalAPI(promptMessages);
} else {
response = await generateRaw({
prompt: promptMessages,
quietToLoud: false
});
}
if (response) {
const prompts = parseAvatarPromptsResponse(response);
+141 -10
View File
@@ -30,6 +30,123 @@ 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 (!apiKey || !apiKey.trim()) {
throw new Error('External API key is not found. If you switched browsers or cleared your cache, please re-enter your API key in the extension settings.');
}
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}`);
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey.trim()}`
},
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 || !apiKey || !model) {
return {
success: false,
message: !apiKey
? 'API Key not found. Please re-enter it in settings (keys are stored locally per-browser).'
: 'Please fill in all required fields (Base URL, API Key, and Model)'
};
}
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
@@ -100,11 +217,13 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
return;
}
if (extensionSettings.generationMode !== 'separate') {
// console.log('[RPG Companion] Not in separate mode, skipping manual update');
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);
@@ -114,13 +233,14 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
$updateBtn.html(`<i class="fa-solid fa-spinner fa-spin"></i> ${updatingText}`).prop('disabled', true);
// Save current preset name before switching (if we're going to switch)
if (extensionSettings.useSeparatePreset) {
// Note: Preset switching is only used in separate mode, not external mode
if (!isExternalMode && extensionSettings.useSeparatePreset) {
originalPresetName = await getCurrentPresetName();
console.log(`[RPG Companion] Saved original preset: "${originalPresetName}"`);
}
// Switch to separate preset if enabled
if (extensionSettings.useSeparatePreset) {
// Switch to separate preset if enabled (separate mode only)
if (!isExternalMode && extensionSettings.useSeparatePreset) {
const switched = await switchToPreset('RPG Companion Trackers');
if (!switched) {
console.warn('[RPG Companion] Failed to switch to RPG Companion Trackers preset. Using current preset.');
@@ -130,11 +250,19 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
const prompt = await generateSeparateUpdatePrompt();
// Generate using raw prompt (uses current preset, no chat history)
const response = await generateRaw({
prompt: prompt,
quietToLoud: false
});
// 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);
@@ -245,6 +373,9 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
} catch (error) {
console.error('[RPG Companion] Error updating RPG data:', error);
if (isExternalMode) {
toastr.error(error.message, 'RPG Companion External API Error');
}
} finally {
// Restore original preset if we switched to a separate one
if (originalPresetName && extensionSettings.useSeparatePreset) {
+10 -1
View File
@@ -293,8 +293,17 @@ export function updateGenerationModeUI() {
if (extensionSettings.generationMode === 'together') {
// In "together" mode, manual update button is hidden
$('#rpg-manual-update').hide();
} else {
$('#rpg-external-api-settings').slideUp(200);
$('#rpg-separate-mode-settings').slideUp(200);
} else if (extensionSettings.generationMode === 'separate') {
// In "separate" mode, manual update button is visible
$('#rpg-manual-update').show();
$('#rpg-external-api-settings').slideUp(200);
$('#rpg-separate-mode-settings').slideDown(200);
} else if (extensionSettings.generationMode === 'external') {
// In "external" mode, manual update button is visible AND external settings are shown
$('#rpg-manual-update').show();
$('#rpg-external-api-settings').slideDown(200);
$('#rpg-separate-mode-settings').slideUp(200);
}
}