Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const nextConfig = {
reactStrictMode: true,
swcMinify: true,
experimental: {
instrumentationHook: true,
serverActions: {
bodySizeLimit: '2mb',
},
Expand Down
31 changes: 30 additions & 1 deletion src/components/layout/SidebarContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<!-- Describe the issue here -->`;
const issueUrl = `${repoUrl}/issues/new?title=&body=${encodeURIComponent(issueBody)}&labels=bug`;

return (
<>
{/* Logo/Brand */}
Expand Down Expand Up @@ -138,6 +144,29 @@ export function SidebarContent({ sidebarHierarchy, examId, onNavigate }: Sidebar
</Link>
<ThemeToggle />
</div>
<div className="flex items-center gap-3">
<a
href={repoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
title="View on GitHub"
>
<Github className="h-3.5 w-3.5" />
GitHub
</a>
<span className="text-muted-foreground/40">|</span>
<a
href={issueUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
title="Report an issue"
>
<Bug className="h-3.5 w-3.5" />
Report Issue
</a>
</div>
</div>
</>
);
Expand Down
6 changes: 6 additions & 0 deletions src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { validateAndLogEnv } = await import('./lib/env');
validateAndLogEnv();
}
}
68 changes: 68 additions & 0 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
6 changes: 3 additions & 3 deletions src/lib/llm/__tests__/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
17 changes: 4 additions & 13 deletions src/lib/llm/provider.ts
Original file line number Diff line number Diff line change
@@ -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<ProviderName, LLMProvider> = {
claude: claudeProvider,
Expand All @@ -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];
Expand Down