diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 57d3124..85e96e2 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -5,7 +5,7 @@ import { NextResponse } from 'next/server'; import { checkDatabaseHealth } from '@/services/database'; -import { hasValidOpenAiKey } from '@/services/database/descriptionService'; +import { hasValidOpenAiKey } from '@/services/storage/apiKeys'; export const runtime = 'nodejs'; diff --git a/src/components/onboarding/FirstRunModal.tsx b/src/components/onboarding/FirstRunModal.tsx index a03d821..aa08dd8 100644 --- a/src/components/onboarding/FirstRunModal.tsx +++ b/src/components/onboarding/FirstRunModal.tsx @@ -2,49 +2,19 @@ import { useState, useEffect, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; -import { apiKeyService } from '@/services/storage/apiKeys'; +import { isFirstRun, markFirstRunComplete } from '@/services/storage/apiKeys'; export default function FirstRunModal() { const [isOpen, setIsOpen] = useState(false); - const [apiKey, setApiKey] = useState(''); - const [status, setStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); - const [errorMessage, setErrorMessage] = useState(''); useEffect(() => { - // Check if this is first run - if (apiKeyService.isFirstRun()) { + if (isFirstRun()) { setIsOpen(true); } }, []); - const handleSaveKey = async () => { - if (!apiKey.trim()) return; - - setStatus('testing'); - setErrorMessage(''); - - try { - apiKeyService.setOpenAiKey(apiKey.trim()); - const ok = await apiKeyService.testOpenAiConnection(); - - if (ok) { - setStatus('success'); - setTimeout(() => { - apiKeyService.markFirstRunComplete(); - setIsOpen(false); - }, 1000); - } else { - setStatus('error'); - setErrorMessage('Could not connect to OpenAI. Please check your key.'); - } - } catch (error) { - setStatus('error'); - setErrorMessage('Invalid API key format. Keys start with sk-'); - } - }; - - const handleSkip = () => { - apiKeyService.markFirstRunComplete(); + const handleClose = () => { + markFirstRunComplete(); setIsOpen(false); }; @@ -53,54 +23,28 @@ export default function FirstRunModal() { return createPortal(
e.stopPropagation()}> - {/* Content */}

- To use automated features (embeddings, auto-organise, smart descriptions), - you'll need an OpenAI API key. + To use AI features (embeddings, auto-descriptions, smart tagging), + add your OpenAI API key to .env.local:

+
+ OPENAI_API_KEY=sk-your-key-here +

- Average cost for heavy use is less than $0.10 per day. + Then restart the app. Average cost for heavy use is less than $0.10/day.

-
- setApiKey(e.target.value)} - placeholder="sk-..." - style={inputStyle} - autoFocus - /> - {errorMessage &&
{errorMessage}
} - {status === 'success' && ( -
Connected successfully!
- )} -
-
- - -
-

- You can add or change your key later in Settings → API Keys. -

+

Without a key, you can still create and organise nodes manually.

({ openai: 'not-set' }); - const [showKey, setShowKey] = useState(false); + const [status, setStatus] = useState<'checking' | 'configured' | 'not-set'>('checking'); useEffect(() => { - if (apiKeyService.hasOpenAiKey()) { - setOpenaiKey(apiKeyService.getMaskedOpenAiKey()); - } - setStatus(apiKeyService.getStatus()); + // Check via health endpoint (server-side check of process.env) + fetch('/api/health') + .then(res => res.json()) + .then(data => { + setStatus(data.aiFeatures?.startsWith('enabled') ? 'configured' : 'not-set'); + }) + .catch(() => setStatus('not-set')); }, []); - const handleSave = async () => { - if (!openaiKey.trim() || openaiKey.includes('•')) return; - try { - apiKeyService.setOpenAiKey(openaiKey.trim()); - setStatus({ openai: 'testing' }); - const ok = await apiKeyService.testOpenAiConnection(); - setStatus({ openai: ok ? 'connected' : 'failed' }); - if (ok) { - setOpenaiKey(apiKeyService.getMaskedOpenAiKey()); - } - } catch (error) { - setStatus({ openai: 'failed' }); - } - }; - - const handleClear = () => { - apiKeyService.clearOpenAiKey(); - setOpenaiKey(''); - setStatus({ openai: 'not-set' }); - }; - - const getStatusLabel = (s: string) => { - if (s === 'connected') return { text: 'Connected', color: '#22c55e' }; - if (s === 'failed') return { text: 'Failed', color: '#ef4444' }; - if (s === 'testing') return { text: 'Testing...', color: '#6b7280' }; - return { text: 'Not configured', color: '#6b7280' }; - }; - - const statusInfo = getStatusLabel(status.openai); - return (

{/* Features explanation */} @@ -56,57 +26,34 @@ export default function ApiKeysViewer() {
  • Semantic search via embeddings
  • - Without a key, you can still create and organize nodes manually. + Without a key, you can still create and organise nodes manually.
    - {/* Key input */} + {/* Status */}
    OpenAI API Key - - {statusInfo.text} + + {status === 'configured' ? 'Configured' : + status === 'checking' ? 'Checking...' : 'Not configured'}
    - setOpenaiKey(e.target.value)} - placeholder="sk-..." - style={inputStyle} - /> - -
    - - - +
    +

    + Add your key to .env.local in the project root: +

    +
    + OPENAI_API_KEY=sk-your-key-here +
    +

    + Restart the app after changing the key. +

    @@ -182,54 +129,30 @@ const cardTitleStyle: CSSProperties = { color: '#e5e7eb', }; -const inputStyle: CSSProperties = { - width: '100%', +const instructionsStyle: CSSProperties = { + fontSize: 13, + color: '#d1d5db', + lineHeight: 1.5, +}; + +const codeInlineStyle: CSSProperties = { + background: 'rgba(255, 255, 255, 0.08)', + padding: '2px 6px', + borderRadius: 4, + fontSize: 12, + fontFamily: 'monospace', + color: '#22c55e', +}; + +const codeBlockStyle: CSSProperties = { + background: 'rgba(0, 0, 0, 0.4)', + border: '1px solid rgba(255, 255, 255, 0.08)', + borderRadius: 6, padding: '10px 12px', fontSize: 13, fontFamily: 'monospace', - background: 'rgba(0, 0, 0, 0.3)', - border: '1px solid rgba(255, 255, 255, 0.08)', - borderRadius: 6, color: '#e5e7eb', - marginBottom: 12, - outline: 'none', -}; - -const buttonRowStyle: CSSProperties = { - display: 'flex', - gap: 8, - alignItems: 'center', -}; - -const btnPrimaryStyle: CSSProperties = { - padding: '8px 14px', - fontSize: 12, - fontWeight: 500, - background: '#22c55e', - color: '#052e16', - border: 'none', - borderRadius: 6, - cursor: 'pointer', -}; - -const btnSecondaryStyle: CSSProperties = { - padding: '8px 14px', - fontSize: 12, - fontWeight: 500, - background: 'rgba(255, 255, 255, 0.06)', - color: '#9ca3af', - border: 'none', - borderRadius: 6, - cursor: 'pointer', -}; - -const toggleStyle: CSSProperties = { - display: 'flex', - alignItems: 'center', - fontSize: 12, - color: '#6b7280', - cursor: 'pointer', - marginLeft: 'auto', + marginBottom: 8, }; const helpStyle: CSSProperties = { diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 8984376..7a6297e 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -9,8 +9,6 @@ import DatabaseViewer from './DatabaseViewer'; import ExternalAgentsPanel from './ExternalAgentsPanel'; import ContextViewer from './ContextViewer'; import GuidesViewer from './GuidesViewer'; -import { apiKeyService } from '@/services/storage/apiKeys'; - export type SettingsTab = | 'logs' | 'tools' @@ -29,16 +27,7 @@ interface SettingsModalProps { type TabType = SettingsTab; export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProps) { - // Default to API Keys tab if no keys are configured, otherwise logs - const getDefaultTab = (): TabType => { - if (typeof window !== 'undefined') { - const hasKey = apiKeyService.getOpenAiKey(); - return hasKey ? 'logs' : 'apikeys'; - } - return 'logs'; - }; - - const [activeTab, setActiveTab] = useState(getDefaultTab()); + const [activeTab, setActiveTab] = useState('logs'); useEffect(() => { if (!isOpen) return; diff --git a/src/services/database/descriptionService.ts b/src/services/database/descriptionService.ts index 583f803..2ec9d26 100644 --- a/src/services/database/descriptionService.ts +++ b/src/services/database/descriptionService.ts @@ -1,5 +1,6 @@ import { openai as openaiProvider } from '@ai-sdk/openai'; import { generateText } from 'ai'; +import { hasValidOpenAiKey } from '../storage/apiKeys'; export interface DescriptionInput { title: string; @@ -15,16 +16,8 @@ export interface DescriptionInput { dimensions?: string[]; } -/** - * Check if we have a valid OpenAI API key configured. - * Checks both environment variable and validates format. - */ -export function hasValidOpenAiKey(): boolean { - const key = process.env.OPENAI_API_KEY; - if (!key || key === 'your-openai-api-key-here') return false; - // Valid OpenAI keys start with sk- or sk-proj- - return key.startsWith('sk-') && key.length > 20; -} +// Re-export for backwards compatibility — canonical source is ../storage/apiKeys +export { hasValidOpenAiKey } from '../storage/apiKeys'; /** * Generate a simple fallback description without AI. diff --git a/src/services/database/dimensionService.ts b/src/services/database/dimensionService.ts index 28c2d68..52c1ed3 100644 --- a/src/services/database/dimensionService.ts +++ b/src/services/database/dimensionService.ts @@ -1,7 +1,7 @@ import { getSQLiteClient } from './sqlite-client'; import { openai as openaiProvider } from '@ai-sdk/openai'; import { generateText } from 'ai'; -import { hasValidOpenAiKey } from './descriptionService'; +import { hasValidOpenAiKey } from '../storage/apiKeys'; export interface Dimension { name: string; diff --git a/src/services/database/edges.ts b/src/services/database/edges.ts index c355772..5f1aebf 100644 --- a/src/services/database/edges.ts +++ b/src/services/database/edges.ts @@ -2,7 +2,6 @@ import { getSQLiteClient } from './sqlite-client'; import { Edge, EdgeContext, EdgeData, EdgeCreatedVia, NodeConnection, Node } from '@/types/database'; import { eventBroadcaster } from '../events'; import { nodeService } from './nodes'; -import { apiKeyService } from '../storage/apiKeys'; import { generateText } from 'ai'; import { createOpenAI } from '@ai-sdk/openai'; import { z } from 'zod'; @@ -54,7 +53,7 @@ async function inferEdgeContext(params: { // If no API key is configured, degrade gracefully. // We still enforce explanation, but fall back to "related_to" classification. - const apiKey = apiKeyService.getOpenAiKey(); + const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) { return { type: 'related_to', confidence: 0.0, swap_direction: false }; } @@ -119,7 +118,7 @@ async function autoInferEdge(params: { }): Promise<{ explanation: string; type: EdgeContext['type']; confidence: number; swap_direction: boolean }> { const { fromNode, toNode } = params; - const apiKey = apiKeyService.getOpenAiKey(); + const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) { // Fallback without AI return { diff --git a/src/services/embeddings.ts b/src/services/embeddings.ts index 57ff86f..8129376 100644 --- a/src/services/embeddings.ts +++ b/src/services/embeddings.ts @@ -1,11 +1,9 @@ import OpenAI from 'openai'; -import { apiKeyService } from './storage/apiKeys'; -// Initialize OpenAI client with dynamic API key support function getOpenAiClient(): OpenAI { - const apiKey = apiKeyService.getOpenAiKey(); + const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) { - throw new Error('OpenAI API key required. Please:\n1. Click the Settings icon (⚙️) in the bottom left\n2. Go to API Keys tab\n3. Add your OpenAI API key\n\nGet your key at: https://platform.openai.com/api-keys'); + throw new Error('OpenAI API key not configured. Add OPENAI_API_KEY to your .env.local file.'); } return new OpenAI({ apiKey }); } @@ -41,4 +39,4 @@ export class EmbeddingService { static validateEmbedding(embedding: number[]): boolean { return Array.isArray(embedding) && embedding.length === 1536; } -} \ No newline at end of file +} diff --git a/src/services/storage/apiKeys.ts b/src/services/storage/apiKeys.ts index e778650..4429f63 100644 --- a/src/services/storage/apiKeys.ts +++ b/src/services/storage/apiKeys.ts @@ -1,148 +1,34 @@ -// API Key Storage Service -// Handles storage and retrieval of OpenAI API key +// API Key helpers +// Single source of truth: .env.local → process.env.OPENAI_API_KEY -export interface ApiKeyStatus { - openai: 'connected' | 'failed' | 'testing' | 'not-set'; +/** + * Check if a valid OpenAI API key is configured in .env.local + */ +export function hasValidOpenAiKey(): boolean { + const key = process.env.OPENAI_API_KEY; + if (!key || key === 'your-openai-api-key-here') return false; + return key.startsWith('sk-') && key.length > 20; } -const STORAGE_KEY = 'ra-h-api-keys'; -const FIRST_RUN_KEY = 'ra-h-first-run-complete'; - -export class ApiKeyService { - private static instance: ApiKeyService; - private openaiKey: string | undefined; - private status: ApiKeyStatus = { - openai: 'not-set', - }; - - static getInstance(): ApiKeyService { - if (!ApiKeyService.instance) { - ApiKeyService.instance = new ApiKeyService(); - } - return ApiKeyService.instance; - } - - constructor() { - this.loadKeys(); - } - - // Load keys from localStorage - private loadKeys(): void { - try { - if (typeof window !== 'undefined') { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - const parsed = JSON.parse(stored); - this.openaiKey = parsed.openai; - } - } - } catch (error) { - console.warn('Failed to load API keys from storage:', error); - this.openaiKey = undefined; - } - } - - // Save keys to localStorage - private saveKeys(): void { - try { - if (typeof window !== 'undefined') { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ openai: this.openaiKey })); - } - } catch (error) { - console.error('Failed to save API keys to storage:', error); - } - } - - // Get OpenAI API key (user key or fallback to env) - getOpenAiKey(): string | undefined { - // Priority: User key > Environment key - return this.openaiKey || process.env.OPENAI_API_KEY; - } - - // Set OpenAI API key - setOpenAiKey(key: string): void { - if (this.validateOpenAiKey(key)) { - this.openaiKey = key; - this.saveKeys(); - } else { - throw new Error('Invalid OpenAI API key format'); - } - } - - // Clear OpenAI key - clearOpenAiKey(): void { - this.openaiKey = undefined; - this.saveKeys(); - this.status.openai = 'not-set'; - } - - // Get masked key for display (show only last 4 characters) - getMaskedOpenAiKey(): string { - if (!this.openaiKey) return ''; - return '••••••••••••••••••••' + this.openaiKey.slice(-4); - } - - // Check if user has provided a key - hasOpenAiKey(): boolean { - return !!this.openaiKey; - } - - // Validate OpenAI key format - private validateOpenAiKey(key: string): boolean { - return ( - typeof key === 'string' && - key.length > 20 && - (key.startsWith('sk-') || key.startsWith('sk-proj-')) - ); - } - - // Test connection to OpenAI - async testOpenAiConnection(key?: string): Promise { - const testKey = key || this.getOpenAiKey(); - if (!testKey) return false; - - this.status.openai = 'testing'; - - try { - const response = await fetch('https://api.openai.com/v1/models', { - headers: { - Authorization: `Bearer ${testKey}`, - 'Content-Type': 'application/json', - }, - }); - - const isConnected = response.ok; - this.status.openai = isConnected ? 'connected' : 'failed'; - return isConnected; - } catch (error) { - console.error('OpenAI connection test failed:', error); - this.status.openai = 'failed'; - return false; - } - } - - // Get connection status - getStatus(): ApiKeyStatus { - return { ...this.status }; - } - - // Update status - updateStatus(status: ApiKeyStatus['openai']): void { - this.status.openai = status; - } - - // First-run tracking - isFirstRun(): boolean { - if (typeof window === 'undefined') return false; - return !localStorage.getItem(FIRST_RUN_KEY); - } - - markFirstRunComplete(): void { - if (typeof window !== 'undefined') { - localStorage.setItem(FIRST_RUN_KEY, 'true'); - } - } +/** + * Get the OpenAI API key from environment + */ +export function getOpenAiKey(): string | undefined { + const key = process.env.OPENAI_API_KEY; + if (!key || key === 'your-openai-api-key-here') return undefined; + return key; } -// Export singleton instance -export const apiKeyService = ApiKeyService.getInstance(); +/** + * Check if first run (no API key configured) + */ +export function isFirstRun(): boolean { + if (typeof window === 'undefined') return false; + return !localStorage.getItem('ra-h-first-run-complete'); +} + +export function markFirstRunComplete(): void { + if (typeof window !== 'undefined') { + localStorage.setItem('ra-h-first-run-complete', 'true'); + } +}