diff --git a/next.config.js b/next.config.js index 5a9c23d..0798869 100644 --- a/next.config.js +++ b/next.config.js @@ -3,6 +3,7 @@ const nextConfig = { reactStrictMode: true, swcMinify: true, experimental: { + instrumentationHook: true, serverActions: { bodySizeLimit: '2mb', }, diff --git a/src/components/layout/SidebarContent.tsx b/src/components/layout/SidebarContent.tsx index e6ea9c4..b6e5594 100644 --- a/src/components/layout/SidebarContent.tsx +++ b/src/components/layout/SidebarContent.tsx @@ -9,7 +9,9 @@ import { FlaskConical, TrendingUp, GraduationCap, - ArrowLeftRight + ArrowLeftRight, + Github, + Bug } from 'lucide-react'; import { StudyTreeNav } from './StudyTreeNav'; import { ThemeToggle } from '@/components/ui/theme-toggle'; @@ -54,6 +56,10 @@ export function SidebarContent({ sidebarHierarchy, examId, onNavigate }: Sidebar }, ]; + const repoUrl = 'https://github.com/atbrace/sa-pro-study-companion'; + const issueBody = `**Page:** ${pathname}\n**Exam:** ${config.shortName}\n\n**Description:**\n`; + const issueUrl = `${repoUrl}/issues/new?title=&body=${encodeURIComponent(issueBody)}&labels=bug`; + return ( <> {/* Logo/Brand */} @@ -138,6 +144,29 @@ export function SidebarContent({ sidebarHierarchy, examId, onNavigate }: Sidebar +
+ + + GitHub + + | + + + Report Issue + +
); diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..8fbd507 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,6 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + const { validateAndLogEnv } = await import('./lib/env'); + validateAndLogEnv(); + } +} diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..352388b --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,68 @@ +import type { ProviderName } from './llm/types'; + +const VALID_PROVIDERS: readonly ProviderName[] = ['claude', 'gemini']; + +interface EnvValidationResult { + provider: ProviderName; + warnings: string[]; + errors: string[]; +} + +/** + * Validate LLM-related environment variables. + * Returns structured results so callers can decide how to handle warnings vs errors. + */ +export function validateLLMEnv(): EnvValidationResult { + const warnings: string[] = []; + const errors: string[] = []; + + const rawProvider = process.env.LLM_PROVIDER; + let provider: ProviderName = 'claude'; + + if (!rawProvider) { + warnings.push( + 'LLM_PROVIDER is not set. Defaulting to "claude". AI tutor features require a valid provider and API key.' + ); + } else if (!VALID_PROVIDERS.includes(rawProvider as ProviderName)) { + errors.push( + `Unknown LLM_PROVIDER: "${rawProvider}". Valid options: ${VALID_PROVIDERS.join(', ')}` + ); + return { provider, warnings, errors }; + } else { + provider = rawProvider as ProviderName; + } + + if (provider === 'claude' && !process.env.ANTHROPIC_API_KEY) { + errors.push( + 'ANTHROPIC_API_KEY is required when LLM_PROVIDER=claude. Set it in .env.local to enable AI tutor features.' + ); + } + + if (provider === 'gemini' && !process.env.GOOGLE_AI_API_KEY) { + errors.push( + 'GOOGLE_AI_API_KEY is required when LLM_PROVIDER=gemini. Set it in .env.local to enable AI tutor features.' + ); + } + + return { provider, warnings, errors }; +} + +/** + * Run env validation and log results. Throws on errors if strict mode is enabled. + * In startup context (instrumentation), we warn but don't crash — the app works for + * study content without LLM. The provider factory still throws on actual use. + */ +export function validateAndLogEnv(options: { strict?: boolean } = {}): void { + const { warnings, errors } = validateLLMEnv(); + + for (const warning of warnings) { + console.warn(`[env] WARNING: ${warning}`); + } + + for (const error of errors) { + if (options.strict) { + throw new Error(error); + } + console.error(`[env] ERROR: ${error}`); + } +} diff --git a/src/lib/llm/__tests__/provider.test.ts b/src/lib/llm/__tests__/provider.test.ts index 85854c9..26e9aae 100644 --- a/src/lib/llm/__tests__/provider.test.ts +++ b/src/lib/llm/__tests__/provider.test.ts @@ -37,7 +37,7 @@ describe('provider factory', () => { const { getProvider, resetProvider } = await import('../provider'); resetProvider(); - expect(() => getProvider()).toThrow('ANTHROPIC_API_KEY required'); + expect(() => getProvider()).toThrow('ANTHROPIC_API_KEY is required when LLM_PROVIDER=claude'); }); it('throws on missing GOOGLE_AI_API_KEY for gemini', async () => { @@ -47,7 +47,7 @@ describe('provider factory', () => { const { getProvider, resetProvider } = await import('../provider'); resetProvider(); - expect(() => getProvider()).toThrow('GOOGLE_AI_API_KEY required'); + expect(() => getProvider()).toThrow('GOOGLE_AI_API_KEY is required when LLM_PROVIDER=gemini'); }); it('throws on unknown provider', async () => { @@ -56,7 +56,7 @@ describe('provider factory', () => { const { getProvider, resetProvider } = await import('../provider'); resetProvider(); - expect(() => getProvider()).toThrow('Unknown LLM provider: unknown'); + expect(() => getProvider()).toThrow('Unknown LLM_PROVIDER: "unknown"'); }); it('caches provider instance', async () => { diff --git a/src/lib/llm/provider.ts b/src/lib/llm/provider.ts index 86091cb..e96d1f5 100644 --- a/src/lib/llm/provider.ts +++ b/src/lib/llm/provider.ts @@ -1,6 +1,7 @@ import type { LLMProvider, ProviderName } from './types'; import { claudeProvider } from './providers/claude'; import { geminiProvider } from './providers/gemini'; +import { validateLLMEnv } from '../env'; const providers: Record = { claude: claudeProvider, @@ -12,20 +13,10 @@ let cachedProvider: LLMProvider | null = null; export function getProvider(): LLMProvider { if (cachedProvider) return cachedProvider; - const name = (process.env.LLM_PROVIDER || 'claude') as ProviderName; + const { provider: name, errors } = validateLLMEnv(); - if (!providers[name]) { - throw new Error( - `Unknown LLM provider: ${name}. Valid options: ${Object.keys(providers).join(', ')}` - ); - } - - // Validate required env vars - if (name === 'claude' && !process.env.ANTHROPIC_API_KEY) { - throw new Error('ANTHROPIC_API_KEY required when LLM_PROVIDER=claude'); - } - if (name === 'gemini' && !process.env.GOOGLE_AI_API_KEY) { - throw new Error('GOOGLE_AI_API_KEY required when LLM_PROVIDER=gemini'); + if (errors.length > 0) { + throw new Error(errors[0]); } cachedProvider = providers[name];