mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Fix gemini key
This commit is contained in:
Vendored
+23
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"workbench.colorCustomizations": {
|
||||||
|
"activityBar.activeBackground": "#9671ea",
|
||||||
|
"activityBar.background": "#9671ea",
|
||||||
|
"activityBar.foreground": "#15202b",
|
||||||
|
"activityBar.inactiveForeground": "#15202b99",
|
||||||
|
"activityBarBadge.background": "#8b3915",
|
||||||
|
"activityBarBadge.foreground": "#e7e7e7",
|
||||||
|
"commandCenter.border": "#e7e7e799",
|
||||||
|
"sash.hoverBorder": "#9671ea",
|
||||||
|
"statusBar.background": "#7545e3",
|
||||||
|
"statusBar.foreground": "#e7e7e7",
|
||||||
|
"statusBarItem.hoverBackground": "#9671ea",
|
||||||
|
"statusBarItem.remoteBackground": "#7545e3",
|
||||||
|
"statusBarItem.remoteForeground": "#e7e7e7",
|
||||||
|
"tab.activeBorder": "#9671ea",
|
||||||
|
"titleBar.activeBackground": "#7545e3",
|
||||||
|
"titleBar.activeForeground": "#e7e7e7",
|
||||||
|
"titleBar.inactiveBackground": "#7545e399",
|
||||||
|
"titleBar.inactiveForeground": "#e7e7e799"
|
||||||
|
},
|
||||||
|
"peacock.color": "#7545e3"
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ import { RMABLogger } from '@/lib/utils/logger';
|
|||||||
const logger = RMABLogger.create('API.BookDate.TestConnection');
|
const logger = RMABLogger.create('API.BookDate.TestConnection');
|
||||||
|
|
||||||
// Fetch available Claude models from the Anthropic API
|
// Fetch available Claude models from the Anthropic API
|
||||||
async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: string }[]> {
|
async function fetchClaudeModels(
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<{ id: string; name: string }[]> {
|
||||||
const allModels: { id: string; name: string }[] = [];
|
const allModels: { id: string; name: string }[] = [];
|
||||||
let afterId: string | undefined;
|
let afterId: string | undefined;
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: st
|
|||||||
'x-api-key': apiKey,
|
'x-api-key': apiKey,
|
||||||
'anthropic-version': '2023-06-01',
|
'anthropic-version': '2023-06-01',
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -53,9 +55,12 @@ async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch available Gemini models from the Google API
|
// Fetch available Gemini models from the Google API
|
||||||
async function fetchGeminiModels(apiKey: string): Promise<{ id: string; name: string }[]> {
|
async function fetchGeminiModels(
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<{ id: string; name: string }[]> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`
|
'https://generativelanguage.googleapis.com/v1beta/models',
|
||||||
|
{ headers: { 'x-goog-api-key': apiKey } },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -67,7 +72,11 @@ async function fetchGeminiModels(apiKey: string): Promise<{ id: string; name: st
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
return (data.models || [])
|
return (data.models || [])
|
||||||
.filter((m: any) => m.name?.startsWith('models/gemini-') && m.supportedGenerationMethods?.includes('generateContent'))
|
.filter(
|
||||||
|
(m: any) =>
|
||||||
|
m.name?.startsWith('models/gemini-') &&
|
||||||
|
m.supportedGenerationMethods?.includes('generateContent'),
|
||||||
|
)
|
||||||
.map((m: any) => ({
|
.map((m: any) => ({
|
||||||
id: m.name.replace('models/', ''),
|
id: m.name.replace('models/', ''),
|
||||||
name: m.displayName || m.name.replace('models/', ''),
|
name: m.displayName || m.name.replace('models/', ''),
|
||||||
@@ -98,14 +107,17 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
if (!provider) {
|
if (!provider) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Provider is required' },
|
{ error: 'Provider is required' },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
|
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
|
{
|
||||||
{ status: 400 }
|
error:
|
||||||
|
'Invalid provider. Must be "openai", "claude", "custom", or "gemini"',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,15 +126,18 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
if (!baseUrl && !useSavedKey) {
|
if (!baseUrl && !useSavedKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Base URL is required for custom provider' },
|
{ error: 'Base URL is required for custom provider' },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlToValidate = useSavedKey ? null : baseUrl; // Will check saved URL later if useSavedKey
|
const urlToValidate = useSavedKey ? null : baseUrl; // Will check saved URL later if useSavedKey
|
||||||
if (urlToValidate && !isValidBaseUrl(urlToValidate)) {
|
if (urlToValidate && !isValidBaseUrl(urlToValidate)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid base URL format. Must start with http:// or https://' },
|
{
|
||||||
{ status: 400 }
|
error:
|
||||||
|
'Invalid base URL format. Must start with http:// or https://',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,14 +147,15 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
let testBaseUrl = baseUrl;
|
let testBaseUrl = baseUrl;
|
||||||
if (useSavedKey) {
|
if (useSavedKey) {
|
||||||
const { prisma } = await import('@/lib/db');
|
const { prisma } = await import('@/lib/db');
|
||||||
const { getEncryptionService } = await import('@/lib/services/encryption.service');
|
const { getEncryptionService } =
|
||||||
|
await import('@/lib/services/encryption.service');
|
||||||
|
|
||||||
const config = await prisma.bookDateConfig.findFirst();
|
const config = await prisma.bookDateConfig.findFirst();
|
||||||
|
|
||||||
if (!config || !config.apiKey) {
|
if (!config || !config.apiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'No saved configuration found' },
|
{ error: 'No saved configuration found' },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +167,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
if (provider !== 'custom') {
|
if (provider !== 'custom') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to decrypt saved API key' },
|
{ error: 'Failed to decrypt saved API key' },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
testApiKey = '';
|
testApiKey = '';
|
||||||
@@ -162,7 +178,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
if (!testBaseUrl) {
|
if (!testBaseUrl) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'No saved base URL found for custom provider' },
|
{ error: 'No saved base URL found for custom provider' },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,7 +188,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
if (!testApiKey && provider !== 'custom') {
|
if (!testApiKey && provider !== 'custom') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'API key is required' },
|
{ error: 'API key is required' },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +198,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
// OpenAI: Fetch models from API
|
// OpenAI: Fetch models from API
|
||||||
const response = await fetch('https://api.openai.com/v1/models', {
|
const response = await fetch('https://api.openai.com/v1/models', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${testApiKey}`,
|
Authorization: `Bearer ${testApiKey}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,7 +207,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
logger.error('OpenAI API error', { error: errorText });
|
logger.error('OpenAI API error', { error: errorText });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid OpenAI API key or connection failed' },
|
{ error: 'Invalid OpenAI API key or connection failed' },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +221,6 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
name: m.id,
|
name: m.id,
|
||||||
}))
|
}))
|
||||||
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
} else if (provider === 'claude') {
|
} else if (provider === 'claude') {
|
||||||
// Claude: Fetch models dynamically from the Anthropic Models API
|
// Claude: Fetch models dynamically from the Anthropic Models API
|
||||||
try {
|
try {
|
||||||
@@ -213,7 +228,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid Claude API key or connection failed' },
|
{ error: 'Invalid Claude API key or connection failed' },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (provider === 'gemini') {
|
} else if (provider === 'gemini') {
|
||||||
@@ -223,7 +238,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid Gemini API key or connection failed' },
|
{ error: 'Invalid Gemini API key or connection failed' },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (provider === 'custom') {
|
} else if (provider === 'custom') {
|
||||||
@@ -244,11 +259,15 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
logger.error('Custom provider connection error', { error: errorText });
|
logger.error('Custom provider connection error', {
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
// Return 400 (not the external service's status) to prevent triggering logout on 401
|
// Return 400 (not the external service's status) to prevent triggering logout on 401
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Failed to connect to custom provider: ${response.status} ${errorText}` },
|
{
|
||||||
{ status: 400 }
|
error: `Failed to connect to custom provider: ${response.status} ${errorText}`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +292,8 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
models: [],
|
models: [],
|
||||||
message: 'Connected successfully but could not parse models list. You may need to enter model name manually.',
|
message:
|
||||||
|
'Connected successfully but could not parse models list. You may need to enter model name manually.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,8 +301,10 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Custom provider network error', { error: error.message });
|
logger.error('Custom provider network error', { error: error.message });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Network error connecting to custom provider: ${error.message}` },
|
{
|
||||||
{ status: 500 }
|
error: `Network error connecting to custom provider: ${error.message}`,
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,12 +314,13 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
|||||||
models,
|
models,
|
||||||
provider,
|
provider,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Test connection error', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Test connection error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message || 'Connection test failed' },
|
{ error: error.message || 'Connection test failed' },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,7 +335,7 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
if (useSavedKey) {
|
if (useSavedKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Authentication required to use saved API key' },
|
{ error: 'Authentication required to use saved API key' },
|
||||||
{ status: 401 }
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,14 +343,17 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
if (!provider) {
|
if (!provider) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Provider is required' },
|
{ error: 'Provider is required' },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
|
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
|
{
|
||||||
{ status: 400 }
|
error:
|
||||||
|
'Invalid provider. Must be "openai", "claude", "custom", or "gemini"',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,14 +362,17 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Base URL is required for custom provider' },
|
{ error: 'Base URL is required for custom provider' },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidBaseUrl(baseUrl)) {
|
if (!isValidBaseUrl(baseUrl)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid base URL format. Must start with http:// or https://' },
|
{
|
||||||
{ status: 400 }
|
error:
|
||||||
|
'Invalid base URL format. Must start with http:// or https://',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,7 +381,7 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
if (!apiKey && provider !== 'custom') {
|
if (!apiKey && provider !== 'custom') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'API key is required' },
|
{ error: 'API key is required' },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +391,7 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
// OpenAI: Fetch models from API
|
// OpenAI: Fetch models from API
|
||||||
const response = await fetch('https://api.openai.com/v1/models', {
|
const response = await fetch('https://api.openai.com/v1/models', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -371,7 +400,7 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
logger.error('OpenAI API error', { error: errorText });
|
logger.error('OpenAI API error', { error: errorText });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid OpenAI API key or connection failed' },
|
{ error: 'Invalid OpenAI API key or connection failed' },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +414,6 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
name: m.id,
|
name: m.id,
|
||||||
}))
|
}))
|
||||||
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
} else if (provider === 'claude') {
|
} else if (provider === 'claude') {
|
||||||
// Claude: Fetch models dynamically from the Anthropic Models API
|
// Claude: Fetch models dynamically from the Anthropic Models API
|
||||||
try {
|
try {
|
||||||
@@ -393,7 +421,7 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid Claude API key or connection failed' },
|
{ error: 'Invalid Claude API key or connection failed' },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (provider === 'gemini') {
|
} else if (provider === 'gemini') {
|
||||||
@@ -403,7 +431,7 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid Gemini API key or connection failed' },
|
{ error: 'Invalid Gemini API key or connection failed' },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (provider === 'custom') {
|
} else if (provider === 'custom') {
|
||||||
@@ -424,11 +452,15 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
logger.error('Custom provider connection error', { error: errorText });
|
logger.error('Custom provider connection error', {
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
// Return 400 (not the external service's status) to prevent triggering logout on 401
|
// Return 400 (not the external service's status) to prevent triggering logout on 401
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Failed to connect to custom provider: ${response.status} ${errorText}` },
|
{
|
||||||
{ status: 400 }
|
error: `Failed to connect to custom provider: ${response.status} ${errorText}`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +485,8 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
models: [],
|
models: [],
|
||||||
message: 'Connected successfully but could not parse models list. You may need to enter model name manually.',
|
message:
|
||||||
|
'Connected successfully but could not parse models list. You may need to enter model name manually.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,8 +494,10 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Custom provider network error', { error: error.message });
|
logger.error('Custom provider network error', { error: error.message });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Network error connecting to custom provider: ${error.message}` },
|
{
|
||||||
{ status: 500 }
|
error: `Network error connecting to custom provider: ${error.message}`,
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,12 +507,13 @@ async function unauthenticatedHandler(req: NextRequest) {
|
|||||||
models,
|
models,
|
||||||
provider,
|
provider,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Test connection error', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Test connection error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error.message || 'Connection test failed' },
|
{ error: error.message || 'Connection test failed' },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+166
-94
@@ -50,7 +50,7 @@ export interface AIRecommendation {
|
|||||||
*/
|
*/
|
||||||
async function enrichWithUserRatings(
|
async function enrichWithUserRatings(
|
||||||
userId: string,
|
userId: string,
|
||||||
cachedBooks: CachedLibraryBook[]
|
cachedBooks: CachedLibraryBook[],
|
||||||
): Promise<LibraryBook[]> {
|
): Promise<LibraryBook[]> {
|
||||||
try {
|
try {
|
||||||
// Get user's Plex token, plexId, and role
|
// Get user's Plex token, plexId, and role
|
||||||
@@ -61,7 +61,7 @@ async function enrichWithUserRatings(
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
logger.warn('User not found');
|
logger.warn('User not found');
|
||||||
return cachedBooks.map(book => ({
|
return cachedBooks.map((book) => ({
|
||||||
title: book.title,
|
title: book.title,
|
||||||
author: book.author,
|
author: book.author,
|
||||||
narrator: book.narrator || undefined,
|
narrator: book.narrator || undefined,
|
||||||
@@ -72,22 +72,28 @@ async function enrichWithUserRatings(
|
|||||||
// Local admin users: Use cached ratings (from system Plex token)
|
// Local admin users: Use cached ratings (from system Plex token)
|
||||||
// Local admins authenticate with username/password, not Plex OAuth
|
// Local admins authenticate with username/password, not Plex OAuth
|
||||||
if (user.plexId.startsWith('local-')) {
|
if (user.plexId.startsWith('local-')) {
|
||||||
logger.info('User is local admin, using cached ratings (from system Plex token)');
|
logger.info(
|
||||||
return cachedBooks.map(book => ({
|
'User is local admin, using cached ratings (from system Plex token)',
|
||||||
|
);
|
||||||
|
return cachedBooks.map((book) => ({
|
||||||
title: book.title,
|
title: book.title,
|
||||||
author: book.author,
|
author: book.author,
|
||||||
narrator: book.narrator || undefined,
|
narrator: book.narrator || undefined,
|
||||||
rating: book.userRating ? parseFloat(book.userRating.toString()) : undefined,
|
rating: book.userRating
|
||||||
|
? parseFloat(book.userRating.toString())
|
||||||
|
: undefined,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plex-authenticated users (including admins): Fetch library with their token to get personal ratings
|
// Plex-authenticated users (including admins): Fetch library with their token to get personal ratings
|
||||||
// Note: /library/sections/{id}/all returns items with the authenticated user's ratings
|
// Note: /library/sections/{id}/all returns items with the authenticated user's ratings
|
||||||
logger.info('User is Plex-authenticated, fetching library with user token to get personal ratings');
|
logger.info(
|
||||||
|
'User is Plex-authenticated, fetching library with user token to get personal ratings',
|
||||||
|
);
|
||||||
|
|
||||||
if (!user.authToken) {
|
if (!user.authToken) {
|
||||||
logger.warn('User has no Plex auth token');
|
logger.warn('User has no Plex auth token');
|
||||||
return cachedBooks.map(book => ({
|
return cachedBooks.map((book) => ({
|
||||||
title: book.title,
|
title: book.title,
|
||||||
author: book.author,
|
author: book.author,
|
||||||
narrator: book.narrator || undefined,
|
narrator: book.narrator || undefined,
|
||||||
@@ -101,7 +107,7 @@ async function enrichWithUserRatings(
|
|||||||
|
|
||||||
if (!plexConfig.serverUrl || !plexConfig.libraryId) {
|
if (!plexConfig.serverUrl || !plexConfig.libraryId) {
|
||||||
logger.warn('No Plex server URL or library ID configured');
|
logger.warn('No Plex server URL or library ID configured');
|
||||||
return cachedBooks.map(book => ({
|
return cachedBooks.map((book) => ({
|
||||||
title: book.title,
|
title: book.title,
|
||||||
author: book.author,
|
author: book.author,
|
||||||
narrator: book.narrator || undefined,
|
narrator: book.narrator || undefined,
|
||||||
@@ -130,7 +136,7 @@ async function enrichWithUserRatings(
|
|||||||
// Get server machine ID from stored config (no need to access system token)
|
// Get server machine ID from stored config (no need to access system token)
|
||||||
if (!plexConfig.machineIdentifier) {
|
if (!plexConfig.machineIdentifier) {
|
||||||
logger.error('Server machine identifier not configured');
|
logger.error('Server machine identifier not configured');
|
||||||
return cachedBooks.map(book => ({
|
return cachedBooks.map((book) => ({
|
||||||
title: book.title,
|
title: book.title,
|
||||||
author: book.author,
|
author: book.author,
|
||||||
narrator: book.narrator || undefined,
|
narrator: book.narrator || undefined,
|
||||||
@@ -141,12 +147,14 @@ async function enrichWithUserRatings(
|
|||||||
const serverMachineId = plexConfig.machineIdentifier;
|
const serverMachineId = plexConfig.machineIdentifier;
|
||||||
const serverAccessToken = await plexService.getServerAccessToken(
|
const serverAccessToken = await plexService.getServerAccessToken(
|
||||||
serverMachineId,
|
serverMachineId,
|
||||||
userPlexToken
|
userPlexToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!serverAccessToken) {
|
if (!serverAccessToken) {
|
||||||
logger.warn('Could not get server access token for user (may not have server access)');
|
logger.warn(
|
||||||
return cachedBooks.map(book => ({
|
'Could not get server access token for user (may not have server access)',
|
||||||
|
);
|
||||||
|
return cachedBooks.map((book) => ({
|
||||||
title: book.title,
|
title: book.title,
|
||||||
author: book.author,
|
author: book.author,
|
||||||
narrator: book.narrator || undefined,
|
narrator: book.narrator || undefined,
|
||||||
@@ -160,14 +168,16 @@ async function enrichWithUserRatings(
|
|||||||
const userLibrary = await plexService.getLibraryContent(
|
const userLibrary = await plexService.getLibraryContent(
|
||||||
plexConfig.serverUrl,
|
plexConfig.serverUrl,
|
||||||
serverAccessToken,
|
serverAccessToken,
|
||||||
plexConfig.libraryId
|
plexConfig.libraryId,
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(`Fetched ${userLibrary.length} items from Plex with user's token`);
|
logger.info(
|
||||||
|
`Fetched ${userLibrary.length} items from Plex with user's token`,
|
||||||
|
);
|
||||||
|
|
||||||
// Create a map of guid/ratingKey -> userRating for quick lookup
|
// Create a map of guid/ratingKey -> userRating for quick lookup
|
||||||
const ratingsMap = new Map<string, number>();
|
const ratingsMap = new Map<string, number>();
|
||||||
userLibrary.forEach(item => {
|
userLibrary.forEach((item) => {
|
||||||
if (item.userRating) {
|
if (item.userRating) {
|
||||||
// Try to match by guid first (most reliable)
|
// Try to match by guid first (most reliable)
|
||||||
if (item.guid) {
|
if (item.guid) {
|
||||||
@@ -183,7 +193,7 @@ async function enrichWithUserRatings(
|
|||||||
logger.info(`Found ${ratingsMap.size} rated items for non-admin user`);
|
logger.info(`Found ${ratingsMap.size} rated items for non-admin user`);
|
||||||
|
|
||||||
// Enrich cached books with user's ratings from the fetched library
|
// Enrich cached books with user's ratings from the fetched library
|
||||||
return cachedBooks.map(book => {
|
return cachedBooks.map((book) => {
|
||||||
// Try to find rating by guid first (most reliable), then ratingKey
|
// Try to find rating by guid first (most reliable), then ratingKey
|
||||||
let rating: number | undefined;
|
let rating: number | undefined;
|
||||||
if (book.plexGuid) {
|
if (book.plexGuid) {
|
||||||
@@ -200,27 +210,37 @@ async function enrichWithUserRatings(
|
|||||||
rating: rating,
|
rating: rating,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (fetchError: any) {
|
} catch (fetchError: any) {
|
||||||
if (fetchError?.response?.status === 401 || fetchError?.message?.includes('401')) {
|
if (
|
||||||
logger.warn('User token unauthorized for library access (shared users may not have direct API access)');
|
fetchError?.response?.status === 401 ||
|
||||||
|
fetchError?.message?.includes('401')
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
'User token unauthorized for library access (shared users may not have direct API access)',
|
||||||
|
);
|
||||||
logger.warn('Falling back to recommendations without user ratings');
|
logger.warn('Falling back to recommendations without user ratings');
|
||||||
} else {
|
} else {
|
||||||
logger.error('Failed to fetch library with user token', { error: fetchError instanceof Error ? fetchError.message : String(fetchError) });
|
logger.error('Failed to fetch library with user token', {
|
||||||
|
error:
|
||||||
|
fetchError instanceof Error
|
||||||
|
? fetchError.message
|
||||||
|
: String(fetchError),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Fallback: return books without ratings
|
// Fallback: return books without ratings
|
||||||
return cachedBooks.map(book => ({
|
return cachedBooks.map((book) => ({
|
||||||
title: book.title,
|
title: book.title,
|
||||||
author: book.author,
|
author: book.author,
|
||||||
narrator: book.narrator || undefined,
|
narrator: book.narrator || undefined,
|
||||||
rating: undefined,
|
rating: undefined,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error enriching books with user ratings', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Error enriching books with user ratings', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
// Fallback: return books without ratings on error
|
// Fallback: return books without ratings on error
|
||||||
return cachedBooks.map(book => ({
|
return cachedBooks.map((book) => ({
|
||||||
title: book.title,
|
title: book.title,
|
||||||
author: book.author,
|
author: book.author,
|
||||||
narrator: book.narrator || undefined,
|
narrator: book.narrator || undefined,
|
||||||
@@ -237,7 +257,7 @@ async function enrichWithUserRatings(
|
|||||||
*/
|
*/
|
||||||
export async function getUserLibraryBooks(
|
export async function getUserLibraryBooks(
|
||||||
userId: string,
|
userId: string,
|
||||||
scope: 'full' | 'listened' | 'rated' | 'favorites'
|
scope: 'full' | 'listened' | 'rated' | 'favorites',
|
||||||
): Promise<LibraryBook[]> {
|
): Promise<LibraryBook[]> {
|
||||||
try {
|
try {
|
||||||
const configService = getConfigService();
|
const configService = getConfigService();
|
||||||
@@ -245,7 +265,9 @@ export async function getUserLibraryBooks(
|
|||||||
|
|
||||||
// Early validation: audiobookshelf doesn't support ratings
|
// Early validation: audiobookshelf doesn't support ratings
|
||||||
if (backendMode === 'audiobookshelf' && scope === 'rated') {
|
if (backendMode === 'audiobookshelf' && scope === 'rated') {
|
||||||
logger.warn('Audiobookshelf does not support ratings, falling back to full library');
|
logger.warn(
|
||||||
|
'Audiobookshelf does not support ratings, falling back to full library',
|
||||||
|
);
|
||||||
scope = 'full';
|
scope = 'full';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,13 +283,17 @@ export async function getUserLibraryBooks(
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (favoriteIds.length === 0) {
|
if (favoriteIds.length === 0) {
|
||||||
logger.warn('Favorites scope selected but no favorites stored, falling back to full library');
|
logger.warn(
|
||||||
|
'Favorites scope selected but no favorites stored, falling back to full library',
|
||||||
|
);
|
||||||
scope = 'full';
|
scope = 'full';
|
||||||
} else {
|
} else {
|
||||||
// Get library ID for filtering
|
// Get library ID for filtering
|
||||||
let libraryId: string;
|
let libraryId: string;
|
||||||
if (backendMode === 'audiobookshelf') {
|
if (backendMode === 'audiobookshelf') {
|
||||||
const absLibraryId = await configService.get('audiobookshelf.library_id');
|
const absLibraryId = await configService.get(
|
||||||
|
'audiobookshelf.library_id',
|
||||||
|
);
|
||||||
if (!absLibraryId) {
|
if (!absLibraryId) {
|
||||||
logger.warn('No Audiobookshelf library ID configured');
|
logger.warn('No Audiobookshelf library ID configured');
|
||||||
return [];
|
return [];
|
||||||
@@ -299,7 +325,9 @@ export async function getUserLibraryBooks(
|
|||||||
orderBy: { addedAt: 'desc' },
|
orderBy: { addedAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Fetched ${cachedBooks.length} favorite books for user ${userId}`);
|
logger.info(
|
||||||
|
`Fetched ${cachedBooks.length} favorite books for user ${userId}`,
|
||||||
|
);
|
||||||
|
|
||||||
// For Plex: Enrich with user's personal ratings
|
// For Plex: Enrich with user's personal ratings
|
||||||
// For Audiobookshelf: Skip enrichment (no rating support)
|
// For Audiobookshelf: Skip enrichment (no rating support)
|
||||||
@@ -307,7 +335,7 @@ export async function getUserLibraryBooks(
|
|||||||
return await enrichWithUserRatings(userId, cachedBooks);
|
return await enrichWithUserRatings(userId, cachedBooks);
|
||||||
} else {
|
} else {
|
||||||
// Audiobookshelf: Map to LibraryBook without ratings
|
// Audiobookshelf: Map to LibraryBook without ratings
|
||||||
return cachedBooks.map(book => ({
|
return cachedBooks.map((book) => ({
|
||||||
title: book.title,
|
title: book.title,
|
||||||
author: book.author,
|
author: book.author,
|
||||||
narrator: book.narrator || undefined,
|
narrator: book.narrator || undefined,
|
||||||
@@ -382,23 +410,24 @@ export async function getUserLibraryBooks(
|
|||||||
|
|
||||||
// Filter to rated books if scope is 'rated'
|
// Filter to rated books if scope is 'rated'
|
||||||
if (scope === 'rated') {
|
if (scope === 'rated') {
|
||||||
const ratedBooks = enrichedBooks.filter(book => book.rating != null);
|
const ratedBooks = enrichedBooks.filter((book) => book.rating != null);
|
||||||
return isLocalAdmin ? ratedBooks : ratedBooks.slice(0, 40);
|
return isLocalAdmin ? ratedBooks : ratedBooks.slice(0, 40);
|
||||||
}
|
}
|
||||||
|
|
||||||
return enrichedBooks;
|
return enrichedBooks;
|
||||||
} else {
|
} else {
|
||||||
// Audiobookshelf: Map to LibraryBook without ratings
|
// Audiobookshelf: Map to LibraryBook without ratings
|
||||||
return cachedBooks.map(book => ({
|
return cachedBooks.map((book) => ({
|
||||||
title: book.title,
|
title: book.title,
|
||||||
author: book.author,
|
author: book.author,
|
||||||
narrator: book.narrator || undefined,
|
narrator: book.narrator || undefined,
|
||||||
rating: undefined, // ABS doesn't support ratings
|
rating: undefined, // ABS doesn't support ratings
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching library books', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Error fetching library books', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,7 +441,7 @@ export async function getUserLibraryBooks(
|
|||||||
*/
|
*/
|
||||||
export async function getUserRecentSwipes(
|
export async function getUserRecentSwipes(
|
||||||
userId: string,
|
userId: string,
|
||||||
limit: number = 10
|
limit: number = 10,
|
||||||
): Promise<SwipeHistory[]> {
|
): Promise<SwipeHistory[]> {
|
||||||
try {
|
try {
|
||||||
// First, get the most recent non-dismiss swipes (left=reject, right=like/request)
|
// First, get the most recent non-dismiss swipes (left=reject, right=like/request)
|
||||||
@@ -458,11 +487,11 @@ export async function getUserRecentSwipes(
|
|||||||
|
|
||||||
// Combine both lists, maintaining chronological order (most recent first)
|
// Combine both lists, maintaining chronological order (most recent first)
|
||||||
const allSwipes = [...nonDismissSwipes, ...dismissSwipes].sort(
|
const allSwipes = [...nonDismissSwipes, ...dismissSwipes].sort(
|
||||||
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Fetched ${allSwipes.length} swipes: ${nonDismissSwipes.length} non-dismiss, ${dismissSwipes.length} dismiss`
|
`Fetched ${allSwipes.length} swipes: ${nonDismissSwipes.length} non-dismiss, ${dismissSwipes.length} dismiss`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return allSwipes.map((s) => ({
|
return allSwipes.map((s) => ({
|
||||||
@@ -471,9 +500,10 @@ export async function getUserRecentSwipes(
|
|||||||
action: s.action,
|
action: s.action,
|
||||||
markedAsKnown: s.markedAsKnown,
|
markedAsKnown: s.markedAsKnown,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching swipe history', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Error fetching swipe history', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -486,11 +516,11 @@ export async function getUserRecentSwipes(
|
|||||||
*/
|
*/
|
||||||
export async function buildAIPrompt(
|
export async function buildAIPrompt(
|
||||||
userId: string,
|
userId: string,
|
||||||
config: { libraryScope: string; customPrompt?: string | null }
|
config: { libraryScope: string; customPrompt?: string | null },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const libraryBooks = await getUserLibraryBooks(
|
const libraryBooks = await getUserLibraryBooks(
|
||||||
userId,
|
userId,
|
||||||
config.libraryScope as 'full' | 'listened' | 'rated' | 'favorites'
|
config.libraryScope as 'full' | 'listened' | 'rated' | 'favorites',
|
||||||
);
|
);
|
||||||
|
|
||||||
const swipeHistory = await getUserRecentSwipes(userId, 10);
|
const swipeHistory = await getUserRecentSwipes(userId, 10);
|
||||||
@@ -505,7 +535,7 @@ export async function buildAIPrompt(
|
|||||||
let instructions =
|
let instructions =
|
||||||
'Recommend 15-20 audiobooks the user would enjoy based on their library and swipe history. ' +
|
'Recommend 15-20 audiobooks the user would enjoy based on their library and swipe history. ' +
|
||||||
'CRITICAL RULES:\n' +
|
'CRITICAL RULES:\n' +
|
||||||
'1. DO NOT recommend any books already in the user\'s library (check titles carefully)\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' +
|
'2. DO NOT recommend any books from the swipe history (whether requested, rejected, dismissed, or marked_as_liked)\n' +
|
||||||
'3. You must provide 15-20 diverse recommendations, not just 3-5\n' +
|
'3. You must provide 15-20 diverse recommendations, not just 3-5\n' +
|
||||||
'4. Focus on variety across genres, authors, and styles\n' +
|
'4. Focus on variety across genres, authors, and styles\n' +
|
||||||
@@ -517,8 +547,11 @@ export async function buildAIPrompt(
|
|||||||
|
|
||||||
// Add special instruction for favorites scope
|
// Add special instruction for favorites scope
|
||||||
if (config.libraryScope === 'favorites') {
|
if (config.libraryScope === 'favorites') {
|
||||||
instructions += '\n\n' +
|
instructions +=
|
||||||
'IMPORTANT: The user has specifically handpicked these ' + libraryBooks.length + ' books as their personal favorites. ' +
|
'\n\n' +
|
||||||
|
'IMPORTANT: The user has specifically handpicked these ' +
|
||||||
|
libraryBooks.length +
|
||||||
|
' books as their personal favorites. ' +
|
||||||
'These represent their preferred genres, authors, themes, and styles. Use these as PRIMARY INSPIRATION for your recommendations. ' +
|
'These represent their preferred genres, authors, themes, and styles. Use these as PRIMARY INSPIRATION for your recommendations. ' +
|
||||||
'Find books that capture the essence of what makes these favorites special to the user.';
|
'Find books that capture the essence of what makes these favorites special to the user.';
|
||||||
}
|
}
|
||||||
@@ -527,12 +560,17 @@ export async function buildAIPrompt(
|
|||||||
task: 'recommend_audiobooks',
|
task: 'recommend_audiobooks',
|
||||||
user_context: {
|
user_context: {
|
||||||
library_books: libraryBooks.slice(0, 40),
|
library_books: libraryBooks.slice(0, 40),
|
||||||
swipe_history: swipeHistory.map(s => ({
|
swipe_history: swipeHistory.map((s) => ({
|
||||||
title: s.title,
|
title: s.title,
|
||||||
author: s.author,
|
author: s.author,
|
||||||
user_action: s.action === 'right'
|
user_action:
|
||||||
? (s.markedAsKnown ? 'marked_as_liked' : 'requested')
|
s.action === 'right'
|
||||||
: s.action === 'left' ? 'rejected' : 'dismissed',
|
? s.markedAsKnown
|
||||||
|
? 'marked_as_liked'
|
||||||
|
: 'requested'
|
||||||
|
: s.action === 'left'
|
||||||
|
? 'rejected'
|
||||||
|
: 'dismissed',
|
||||||
})),
|
})),
|
||||||
custom_preferences: config.customPrompt || null,
|
custom_preferences: config.customPrompt || null,
|
||||||
},
|
},
|
||||||
@@ -547,7 +585,7 @@ export async function buildAIPrompt(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Call AI API to get recommendations
|
* Call AI API to get recommendations
|
||||||
* @param provider - 'openai' | 'claude'
|
* @param provider - 'openai' | 'claude' | 'gemini' | 'custom'
|
||||||
* @param model - Model ID
|
* @param model - Model ID
|
||||||
* @param encryptedApiKey - Encrypted API key
|
* @param encryptedApiKey - Encrypted API key
|
||||||
* @param prompt - JSON prompt string
|
* @param prompt - JSON prompt string
|
||||||
@@ -558,7 +596,7 @@ export async function callAI(
|
|||||||
model: string,
|
model: string,
|
||||||
encryptedApiKey: string,
|
encryptedApiKey: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
baseUrl?: string | null
|
baseUrl?: string | null,
|
||||||
): Promise<{ recommendations: AIRecommendation[] }> {
|
): Promise<{ recommendations: AIRecommendation[] }> {
|
||||||
const encryptionService = getEncryptionService();
|
const encryptionService = getEncryptionService();
|
||||||
let apiKey = '';
|
let apiKey = '';
|
||||||
@@ -604,10 +642,11 @@ export async function callAI(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const systemMessage = 'You are an expert audiobook recommender. ' +
|
const systemMessage =
|
||||||
|
'You are an expert audiobook recommender. ' +
|
||||||
'Your task is to recommend 15-20 NEW audiobooks that the user would enjoy. ' +
|
'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. ' +
|
"NEVER recommend books that are already in the user's library or swipe history. " +
|
||||||
'Focus on discovering books they haven\'t seen yet.';
|
"Focus on discovering books they haven't seen yet.";
|
||||||
|
|
||||||
if (provider === 'openai') {
|
if (provider === 'openai') {
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
@@ -630,7 +669,7 @@ export async function callAI(
|
|||||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
@@ -638,7 +677,10 @@ export async function callAI(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
logger.error('OpenAI API error', { status: response.status, error: errorText });
|
logger.error('OpenAI API error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
throw new Error(`OpenAI API error: ${response.status} ${errorText}`);
|
throw new Error(`OpenAI API error: ${response.status} ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,7 +688,6 @@ export async function callAI(
|
|||||||
const content = data.choices[0].message.content;
|
const content = data.choices[0].message.content;
|
||||||
logger.debug('OpenAI response:', { content });
|
logger.debug('OpenAI response:', { content });
|
||||||
return JSON.parse(content);
|
return JSON.parse(content);
|
||||||
|
|
||||||
} else if (provider === 'claude') {
|
} else if (provider === 'claude') {
|
||||||
const userMessage = `${systemMessage}\n\n${prompt}\n\nIMPORTANT: Provide exactly 15-20 recommendations. Return 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 = {
|
const requestBody = {
|
||||||
@@ -674,7 +715,10 @@ export async function callAI(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
logger.error('Claude API error', { status: response.status, error: errorText });
|
logger.error('Claude API error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
throw new Error(`Claude API error: ${response.status} ${errorText}`);
|
throw new Error(`Claude API error: ${response.status} ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,7 +734,6 @@ export async function callAI(
|
|||||||
|
|
||||||
logger.debug('Claude cleaned response:', { cleanedContent });
|
logger.debug('Claude cleaned response:', { cleanedContent });
|
||||||
return JSON.parse(cleanedContent);
|
return JSON.parse(cleanedContent);
|
||||||
|
|
||||||
} else if (provider === 'gemini') {
|
} else if (provider === 'gemini') {
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
systemInstruction: {
|
systemInstruction: {
|
||||||
@@ -702,41 +745,48 @@ export async function callAI(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
generationConfig: {
|
generationConfig: {
|
||||||
responseMimeType: "application/json",
|
responseMimeType: 'application/json',
|
||||||
responseSchema: {
|
responseSchema: {
|
||||||
type: "OBJECT",
|
type: 'OBJECT',
|
||||||
properties: {
|
properties: {
|
||||||
recommendations: {
|
recommendations: {
|
||||||
type: "ARRAY",
|
type: 'ARRAY',
|
||||||
items: {
|
items: {
|
||||||
type: "OBJECT",
|
type: 'OBJECT',
|
||||||
properties: {
|
properties: {
|
||||||
title: { type: "STRING" },
|
title: { type: 'STRING' },
|
||||||
author: { type: "STRING" },
|
author: { type: 'STRING' },
|
||||||
reason: { type: "STRING" },
|
reason: { type: 'STRING' },
|
||||||
},
|
},
|
||||||
required: ["title", "author", "reason"],
|
required: ['title', 'author', 'reason'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ["recommendations"],
|
required: ['recommendations'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.debug('Gemini request body:', { requestBody });
|
logger.debug('Gemini request body:', { requestBody });
|
||||||
|
|
||||||
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`, {
|
const response = await fetch(
|
||||||
method: 'POST',
|
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/json',
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-goog-api-key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
logger.error('Gemini API error', { status: response.status, error: errorText });
|
logger.error('Gemini API error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
throw new Error(`Gemini API error: ${response.status} ${errorText}`);
|
throw new Error(`Gemini API error: ${response.status} ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,7 +807,6 @@ export async function callAI(
|
|||||||
|
|
||||||
logger.debug('Gemini cleaned response:', { cleanedContent });
|
logger.debug('Gemini cleaned response:', { cleanedContent });
|
||||||
return JSON.parse(cleanedContent);
|
return JSON.parse(cleanedContent);
|
||||||
|
|
||||||
} else if (provider === 'custom') {
|
} else if (provider === 'custom') {
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
throw new Error('Base URL is required for custom provider');
|
throw new Error('Base URL is required for custom provider');
|
||||||
@@ -801,13 +850,23 @@ export async function callAI(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
logger.error('Custom provider API error', { status: response.status, error: errorText });
|
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 response_format not supported, retry without it and add instructions to prompt
|
||||||
if (errorText.includes('response_format') || errorText.includes('json_schema')) {
|
if (
|
||||||
logger.info('Retrying without response_format (provider does not support structured outputs)');
|
errorText.includes('response_format') ||
|
||||||
|
errorText.includes('json_schema')
|
||||||
|
) {
|
||||||
|
logger.info(
|
||||||
|
'Retrying without response_format (provider does not support structured outputs)',
|
||||||
|
);
|
||||||
delete requestBody.response_format;
|
delete requestBody.response_format;
|
||||||
requestBody.messages[0].content = systemMessage + ' Return ONLY valid JSON with no additional text or formatting.';
|
requestBody.messages[0].content =
|
||||||
|
systemMessage +
|
||||||
|
' Return ONLY valid JSON with no additional text or formatting.';
|
||||||
|
|
||||||
const retryResponse = await fetch(endpoint, {
|
const retryResponse = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -817,7 +876,9 @@ export async function callAI(
|
|||||||
|
|
||||||
if (!retryResponse.ok) {
|
if (!retryResponse.ok) {
|
||||||
const retryErrorText = await retryResponse.text();
|
const retryErrorText = await retryResponse.text();
|
||||||
throw new Error(`Custom provider API error: ${retryResponse.status} ${retryErrorText}`);
|
throw new Error(
|
||||||
|
`Custom provider API error: ${retryResponse.status} ${retryErrorText}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryData = await retryResponse.json();
|
const retryData = await retryResponse.json();
|
||||||
@@ -829,11 +890,15 @@ export async function callAI(
|
|||||||
.replace(/\s*```$/i, '')
|
.replace(/\s*```$/i, '')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
logger.debug('Custom provider cleaned response (fallback):', { cleanedContent });
|
logger.debug('Custom provider cleaned response (fallback):', {
|
||||||
|
cleanedContent,
|
||||||
|
});
|
||||||
return JSON.parse(cleanedContent);
|
return JSON.parse(cleanedContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Custom provider API error: ${response.status} ${errorText}`);
|
throw new Error(
|
||||||
|
`Custom provider API error: ${response.status} ${errorText}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -847,12 +912,10 @@ export async function callAI(
|
|||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
return JSON.parse(cleanedContent);
|
return JSON.parse(cleanedContent);
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Custom provider error:', error);
|
logger.error('Custom provider error:', error);
|
||||||
throw new Error(`Custom provider error: ${error.message}`);
|
throw new Error(`Custom provider error: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Invalid provider: ${provider}`);
|
throw new Error(`Invalid provider: ${provider}`);
|
||||||
}
|
}
|
||||||
@@ -866,7 +929,7 @@ export async function callAI(
|
|||||||
*/
|
*/
|
||||||
export async function matchToAudnexus(
|
export async function matchToAudnexus(
|
||||||
title: string,
|
title: string,
|
||||||
author: string
|
author: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
asin: string;
|
asin: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -918,7 +981,9 @@ export async function matchToAudnexus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Search Audible.com for the book
|
// Step 2: Search Audible.com for the book
|
||||||
logger.info(`Not in cache, searching Audible for "${title}" by ${author}...`);
|
logger.info(
|
||||||
|
`Not in cache, searching Audible for "${title}" by ${author}...`,
|
||||||
|
);
|
||||||
const audibleService = new AudibleService();
|
const audibleService = new AudibleService();
|
||||||
const searchQuery = `${title} ${author}`;
|
const searchQuery = `${title} ${author}`;
|
||||||
const searchResults = await audibleService.search(searchQuery, 1);
|
const searchResults = await audibleService.search(searchQuery, 1);
|
||||||
@@ -930,7 +995,9 @@ export async function matchToAudnexus(
|
|||||||
|
|
||||||
// Take the first result (best match)
|
// Take the first result (best match)
|
||||||
const firstResult = searchResults.results[0];
|
const firstResult = searchResults.results[0];
|
||||||
logger.info(`Found on Audible: "${firstResult.title}" (ASIN: ${firstResult.asin})`);
|
logger.info(
|
||||||
|
`Found on Audible: "${firstResult.title}" (ASIN: ${firstResult.asin})`,
|
||||||
|
);
|
||||||
|
|
||||||
// Step 3: Use ASIN to fetch full details from Audnexus (or Audible as fallback)
|
// Step 3: Use ASIN to fetch full details from Audnexus (or Audible as fallback)
|
||||||
const details = await audibleService.getAudiobookDetails(firstResult.asin);
|
const details = await audibleService.getAudiobookDetails(firstResult.asin);
|
||||||
@@ -951,9 +1018,10 @@ export async function matchToAudnexus(
|
|||||||
description: details.description || null,
|
description: details.description || null,
|
||||||
coverUrl: details.coverArtUrl || null,
|
coverUrl: details.coverArtUrl || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Audnexus matching error for "${title}"`, { error: error instanceof Error ? error.message : String(error) });
|
logger.error(`Audnexus matching error for "${title}"`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -971,7 +1039,7 @@ export async function isInLibrary(
|
|||||||
userId: string,
|
userId: string,
|
||||||
title: string,
|
title: string,
|
||||||
author: string,
|
author: string,
|
||||||
asin?: string
|
asin?: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Use the centralized matching algorithm from audiobook-matcher.ts
|
// Use the centralized matching algorithm from audiobook-matcher.ts
|
||||||
@@ -983,12 +1051,16 @@ export async function isInLibrary(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
logger.info(`Book "${title}" by ${author} found in library (matched to: "${match.title}")`);
|
logger.info(
|
||||||
|
`Book "${title}" by ${author} found in library (matched to: "${match.title}")`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return !!match;
|
return !!match;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error checking library for "${title}"`, { error: error instanceof Error ? error.message : String(error) });
|
logger.error(`Error checking library for "${title}"`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1001,7 +1073,7 @@ export async function isInLibrary(
|
|||||||
*/
|
*/
|
||||||
export async function isAlreadyRequested(
|
export async function isAlreadyRequested(
|
||||||
userId: string,
|
userId: string,
|
||||||
asin: string
|
asin: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const request = await prisma.request.findFirst({
|
const request = await prisma.request.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -1027,7 +1099,7 @@ export async function isAlreadyRequested(
|
|||||||
export async function isAlreadySwiped(
|
export async function isAlreadySwiped(
|
||||||
userId: string,
|
userId: string,
|
||||||
title: string,
|
title: string,
|
||||||
author: string
|
author: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const swipe = await prisma.bookDateSwipe.findFirst({
|
const swipe = await prisma.bookDateSwipe.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
Reference in New Issue
Block a user