mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add custom AI provider support and improve qBittorrent auth
Introduces support for custom OpenAI-compatible AI providers with configurable base URLs, including UI, backend validation, and connection testing. Enhances qBittorrent integration to support HTTP Basic Auth for reverse proxies, adds detailed debug logging, and updates documentation for both features. Also improves login page description logic and AI prompt generation for recommendations.
This commit is contained in:
+155
-24
@@ -448,25 +448,17 @@ export async function buildAIPrompt(
|
||||
custom_preferences: config.customPrompt || null,
|
||||
},
|
||||
instructions:
|
||||
'Based on the user\'s library and swipe history, recommend 20 audiobooks they would enjoy. ' +
|
||||
'Important rules:\n' +
|
||||
'1. DO NOT recommend any books already in the user\'s library\n' +
|
||||
'Recommend 15-20 audiobooks the user would enjoy based on their library and swipe history. ' +
|
||||
'CRITICAL RULES:\n' +
|
||||
'1. DO NOT recommend any books already in the user\'s library (check titles carefully)\n' +
|
||||
'2. DO NOT recommend any books from the swipe history (whether requested, rejected, dismissed, or marked_as_liked)\n' +
|
||||
'3. Focus on variety and quality\n' +
|
||||
'4. Consider user ratings if available (0-10 scale, higher = liked more)\n' +
|
||||
'5. Learn from rejected books to avoid similar recommendations\n' +
|
||||
'6. Learn from requested books to find similar ones\n' +
|
||||
'7. Pay special attention to "marked_as_liked" books - these are books the user has already read/listened to elsewhere and enjoyed. Find similar books to these.\n' +
|
||||
'Return ONLY valid JSON with no additional text or formatting.',
|
||||
response_format: {
|
||||
recommendations: [
|
||||
{
|
||||
title: 'string',
|
||||
author: 'string',
|
||||
reason: '1-2 sentence explanation',
|
||||
},
|
||||
],
|
||||
},
|
||||
'3. You must provide 15-20 diverse recommendations, not just 3-5\n' +
|
||||
'4. Focus on variety across genres, authors, and styles\n' +
|
||||
'5. Consider user ratings if available (0-10 scale, higher = liked more)\n' +
|
||||
'6. Learn from rejected books to avoid similar recommendations\n' +
|
||||
'7. Learn from requested books to find similar ones\n' +
|
||||
'8. Pay special attention to "marked_as_liked" books - these are books the user has already read/listened to elsewhere and enjoyed. Find similar books to these.\n' +
|
||||
'9. Each recommendation should be a NEW book not mentioned anywhere in the user context',
|
||||
};
|
||||
|
||||
const promptString = JSON.stringify(prompt);
|
||||
@@ -487,18 +479,62 @@ export async function callAI(
|
||||
provider: string,
|
||||
model: string,
|
||||
encryptedApiKey: string,
|
||||
prompt: string
|
||||
prompt: string,
|
||||
baseUrl?: string | null
|
||||
): Promise<{ recommendations: AIRecommendation[] }> {
|
||||
const encryptionService = getEncryptionService();
|
||||
const apiKey = encryptionService.decrypt(encryptedApiKey);
|
||||
let apiKey = '';
|
||||
try {
|
||||
apiKey = encryptionService.decrypt(encryptedApiKey);
|
||||
} catch (error) {
|
||||
// Allow empty API key for custom provider (local models)
|
||||
if (provider !== 'custom') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Calling AI provider: ${provider}, model: ${model}`);
|
||||
|
||||
// Define JSON schema for structured output
|
||||
const responseSchema = {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'audiobook_recommendations',
|
||||
strict: true,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
recommendations: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
author: { type: 'string' },
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
required: ['title', 'author', 'reason'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
minItems: 15,
|
||||
maxItems: 20,
|
||||
},
|
||||
},
|
||||
required: ['recommendations'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const systemMessage = 'You are an expert audiobook recommender. ' +
|
||||
'Your task is to recommend 15-20 NEW audiobooks that the user would enjoy. ' +
|
||||
'NEVER recommend books that are already in the user\'s library or swipe history. ' +
|
||||
'Focus on discovering books they haven\'t seen yet.';
|
||||
|
||||
if (provider === 'openai') {
|
||||
const systemMessage = 'You are an expert audiobook recommender. Analyze user preferences and suggest audiobooks they will love. Return ONLY valid JSON.';
|
||||
const requestBody = {
|
||||
model,
|
||||
response_format: { type: 'json_object' },
|
||||
response_format: responseSchema,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -534,10 +570,10 @@ export async function callAI(
|
||||
return JSON.parse(content);
|
||||
|
||||
} else if (provider === 'claude') {
|
||||
const userMessage = `${prompt}\n\nReturn ONLY valid JSON with no additional text or formatting.`;
|
||||
const userMessage = `${systemMessage}\n\n${prompt}\n\nIMPORTANT: Provide exactly 15-20 recommendations. Return ONLY valid JSON with no additional text or formatting.`;
|
||||
const requestBody = {
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
max_tokens: 8192,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
@@ -577,6 +613,101 @@ export async function callAI(
|
||||
logger.debug('Claude cleaned response:', { cleanedContent });
|
||||
return JSON.parse(cleanedContent);
|
||||
|
||||
} else if (provider === 'custom') {
|
||||
if (!baseUrl) {
|
||||
throw new Error('Base URL is required for custom provider');
|
||||
}
|
||||
|
||||
// Try with json_schema first
|
||||
let requestBody: any = {
|
||||
model,
|
||||
response_format: responseSchema,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemMessage,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
logger.debug('Custom provider request body:', { requestBody, baseUrl });
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Only add Authorization header if API key provided
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const endpoint = baseUrl.replace(/\/$/, '') + '/chat/completions';
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Custom provider API error', { status: response.status, error: errorText });
|
||||
|
||||
// If response_format not supported, retry without it and add instructions to prompt
|
||||
if (errorText.includes('response_format') || errorText.includes('json_schema')) {
|
||||
logger.info('Retrying without response_format (provider does not support structured outputs)');
|
||||
delete requestBody.response_format;
|
||||
requestBody.messages[0].content = systemMessage + ' Return ONLY valid JSON with no additional text or formatting.';
|
||||
|
||||
const retryResponse = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!retryResponse.ok) {
|
||||
const retryErrorText = await retryResponse.text();
|
||||
throw new Error(`Custom provider API error: ${retryResponse.status} ${retryErrorText}`);
|
||||
}
|
||||
|
||||
const retryData = await retryResponse.json();
|
||||
const retryContent = retryData.choices[0].message.content;
|
||||
|
||||
// Clean markdown code blocks
|
||||
const cleanedContent = retryContent
|
||||
.replace(/^```json\s*/i, '')
|
||||
.replace(/\s*```$/i, '')
|
||||
.trim();
|
||||
|
||||
logger.debug('Custom provider cleaned response (fallback):', { cleanedContent });
|
||||
return JSON.parse(cleanedContent);
|
||||
}
|
||||
|
||||
throw new Error(`Custom provider API error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0].message.content;
|
||||
logger.debug('Custom provider response:', { content });
|
||||
|
||||
// Clean potential markdown wrapping (some providers still wrap even with json_schema)
|
||||
const cleanedContent = content
|
||||
.replace(/^```json\s*/i, '')
|
||||
.replace(/\s*```$/i, '')
|
||||
.trim();
|
||||
|
||||
return JSON.parse(cleanedContent);
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('Custom provider error:', error);
|
||||
throw new Error(`Custom provider error: ${error.message}`);
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error(`Invalid provider: ${provider}`);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,11 @@ export class QBittorrentService {
|
||||
baseURL: `${this.baseUrl}/api/v2`,
|
||||
timeout: 30000,
|
||||
httpsAgent: this.httpsAgent,
|
||||
// Support nginx/Apache reverse proxy with HTTP Basic Auth
|
||||
auth: {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,9 +127,20 @@ export class QBittorrentService {
|
||||
* Authenticate and establish session
|
||||
*/
|
||||
async login(): Promise<void> {
|
||||
const loginUrl = `${this.baseUrl}/api/v2/auth/login`;
|
||||
|
||||
logger.debug('[QBittorrent] Attempting login', {
|
||||
url: loginUrl,
|
||||
baseUrl: this.baseUrl,
|
||||
username: this.username,
|
||||
hasPassword: !!this.password,
|
||||
passwordLength: this.password?.length,
|
||||
sslVerifyDisabled: this.disableSSLVerify,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.baseUrl}/api/v2/auth/login`,
|
||||
loginUrl,
|
||||
new URLSearchParams({
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
@@ -136,22 +152,52 @@ export class QBittorrentService {
|
||||
'Origin': this.baseUrl,
|
||||
},
|
||||
httpsAgent: this.httpsAgent,
|
||||
// Support nginx/Apache reverse proxy with HTTP Basic Auth
|
||||
auth: {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug('[QBittorrent] Login response received', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
hasSetCookie: !!response.headers['set-cookie'],
|
||||
setCookieCount: response.headers['set-cookie']?.length || 0,
|
||||
});
|
||||
|
||||
// Extract cookie from response
|
||||
const cookies = response.headers['set-cookie'];
|
||||
if (cookies && cookies.length > 0) {
|
||||
this.cookie = cookies[0].split(';')[0];
|
||||
logger.debug('[QBittorrent] Cookie extracted', {
|
||||
cookieName: this.cookie.split('=')[0],
|
||||
cookieLength: this.cookie.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.cookie) {
|
||||
logger.error('[QBittorrent] No cookie received in response');
|
||||
throw new Error('Failed to authenticate with qBittorrent');
|
||||
}
|
||||
|
||||
logger.info('Successfully authenticated');
|
||||
} catch (error) {
|
||||
logger.error('Login failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error('[QBittorrent] Login failed with axios error', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
requestUrl: error.config?.url,
|
||||
requestHeaders: error.config?.headers,
|
||||
});
|
||||
} else {
|
||||
logger.error('Login failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
throw new Error('Failed to authenticate with qBittorrent');
|
||||
}
|
||||
}
|
||||
@@ -687,6 +733,7 @@ export class QBittorrentService {
|
||||
disableSSLVerify: boolean = false
|
||||
): Promise<string> {
|
||||
const baseUrl = url.replace(/\/$/, '');
|
||||
const loginUrl = `${baseUrl}/api/v2/auth/login`;
|
||||
|
||||
// Create HTTPS agent if SSL verification is disabled
|
||||
let httpsAgent: https.Agent | undefined;
|
||||
@@ -697,36 +744,97 @@ export class QBittorrentService {
|
||||
logger.info('[QBittorrent] SSL certificate verification disabled for test connection');
|
||||
}
|
||||
|
||||
logger.debug('[QBittorrent] Test connection attempt', {
|
||||
loginUrl,
|
||||
baseUrl,
|
||||
username,
|
||||
hasPassword: !!password,
|
||||
passwordLength: password?.length,
|
||||
sslVerifyDisabled: disableSSLVerify,
|
||||
hasHttpsAgent: !!httpsAgent,
|
||||
});
|
||||
|
||||
try {
|
||||
const requestBody = new URLSearchParams({ username, password });
|
||||
const requestHeaders = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Referer': baseUrl,
|
||||
'Origin': baseUrl,
|
||||
};
|
||||
|
||||
logger.debug('[QBittorrent] Sending login request', {
|
||||
body: requestBody.toString(),
|
||||
headers: requestHeaders,
|
||||
});
|
||||
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/api/v2/auth/login`,
|
||||
new URLSearchParams({ username, password }),
|
||||
loginUrl,
|
||||
requestBody,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Referer': baseUrl,
|
||||
'Origin': baseUrl,
|
||||
},
|
||||
headers: requestHeaders,
|
||||
httpsAgent,
|
||||
// Support nginx/Apache reverse proxy with HTTP Basic Auth
|
||||
auth: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug('[QBittorrent] Login response received', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
hasSetCookie: !!response.headers['set-cookie'],
|
||||
setCookieCount: response.headers['set-cookie']?.length || 0,
|
||||
allHeaders: Object.keys(response.headers),
|
||||
});
|
||||
|
||||
// Get version to confirm connection
|
||||
const cookies = response.headers['set-cookie'];
|
||||
if (!cookies || cookies.length === 0) {
|
||||
logger.error('[QBittorrent] No cookies in response', {
|
||||
responseHeaders: response.headers,
|
||||
});
|
||||
throw new Error('Failed to authenticate - no session cookie received');
|
||||
}
|
||||
|
||||
const cookie = cookies[0].split(';')[0];
|
||||
logger.debug('[QBittorrent] Cookie extracted', {
|
||||
cookieName: cookie.split('=')[0],
|
||||
cookieLength: cookie.length,
|
||||
});
|
||||
|
||||
const versionResponse = await axios.get(`${baseUrl}/api/v2/app/version`, {
|
||||
headers: { Cookie: cookie },
|
||||
httpsAgent,
|
||||
// Support nginx/Apache reverse proxy with HTTP Basic Auth
|
||||
auth: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('[QBittorrent] Version check successful', {
|
||||
version: versionResponse.data,
|
||||
});
|
||||
|
||||
return versionResponse.data || 'Connected';
|
||||
} catch (error) {
|
||||
logger.error('Connection test failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error('[QBittorrent] Test connection failed with axios error', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
requestUrl: error.config?.url,
|
||||
requestHeaders: error.config?.headers,
|
||||
responseHeaders: error.response?.headers,
|
||||
});
|
||||
} else {
|
||||
logger.error('Connection test failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
// Enhanced error messages for common issues
|
||||
if (axios.isAxiosError(error)) {
|
||||
|
||||
Reference in New Issue
Block a user