fix: move API keys to .env.local, remove localStorage dual system
Single source of truth: OPENAI_API_KEY in .env.local. Deleted the apiKeyService class/singleton that stored keys in localStorage. All server code reads process.env directly. FirstRunModal and Settings now show .env.local instructions instead of key input. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
49ff3678a3
commit
3b04a1bad0
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { checkDatabaseHealth } from '@/services/database';
|
import { checkDatabaseHealth } from '@/services/database';
|
||||||
import { hasValidOpenAiKey } from '@/services/database/descriptionService';
|
import { hasValidOpenAiKey } from '@/services/storage/apiKeys';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
|||||||
@@ -2,49 +2,19 @@
|
|||||||
|
|
||||||
import { useState, useEffect, type CSSProperties } from 'react';
|
import { useState, useEffect, type CSSProperties } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { apiKeyService } from '@/services/storage/apiKeys';
|
import { isFirstRun, markFirstRunComplete } from '@/services/storage/apiKeys';
|
||||||
|
|
||||||
export default function FirstRunModal() {
|
export default function FirstRunModal() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [apiKey, setApiKey] = useState('');
|
|
||||||
const [status, setStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
|
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if this is first run
|
if (isFirstRun()) {
|
||||||
if (apiKeyService.isFirstRun()) {
|
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSaveKey = async () => {
|
const handleClose = () => {
|
||||||
if (!apiKey.trim()) return;
|
markFirstRunComplete();
|
||||||
|
|
||||||
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();
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,54 +23,28 @@ export default function FirstRunModal() {
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div style={overlayStyle}>
|
<div style={overlayStyle}>
|
||||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||||
{/* Content */}
|
|
||||||
<div style={contentStyle}>
|
<div style={contentStyle}>
|
||||||
<div style={sectionStyle}>
|
<div style={sectionStyle}>
|
||||||
<p style={sectionDescStyle}>
|
<p style={sectionDescStyle}>
|
||||||
To use automated features (embeddings, auto-organise, smart descriptions),
|
To use AI features (embeddings, auto-descriptions, smart tagging),
|
||||||
you'll need an OpenAI API key.
|
add your OpenAI API key to <code style={codeStyle}>.env.local</code>:
|
||||||
</p>
|
|
||||||
<p style={costNoteStyle}>
|
|
||||||
Average cost for heavy use is less than $0.10 per day.
|
|
||||||
</p>
|
</p>
|
||||||
|
<div style={codeBlockStyle}>
|
||||||
|
<code>OPENAI_API_KEY=sk-your-key-here</code>
|
||||||
</div>
|
</div>
|
||||||
|
<p style={costNoteStyle}>
|
||||||
<div style={inputSectionStyle}>
|
Then restart the app. Average cost for heavy use is less than $0.10/day.
|
||||||
<input
|
</p>
|
||||||
type="password"
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
|
||||||
placeholder="sk-..."
|
|
||||||
style={inputStyle}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{errorMessage && <div style={errorStyle}>{errorMessage}</div>}
|
|
||||||
{status === 'success' && (
|
|
||||||
<div style={successStyle}>Connected successfully!</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={buttonSectionStyle}>
|
<div style={buttonSectionStyle}>
|
||||||
<button
|
<button onClick={handleClose} style={primaryButtonStyle}>
|
||||||
onClick={handleSaveKey}
|
Got it
|
||||||
disabled={!apiKey.trim() || status === 'testing'}
|
|
||||||
style={{
|
|
||||||
...primaryButtonStyle,
|
|
||||||
opacity: apiKey.trim() && status !== 'testing' ? 1 : 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status === 'testing' ? 'Testing...' : 'Save & Continue'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button onClick={handleSkip} style={skipButtonStyle}>
|
|
||||||
Skip for now
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={noteStyle}>
|
<div style={noteStyle}>
|
||||||
<p>
|
<p>Without a key, you can still create and organise nodes manually.</p>
|
||||||
You can add or change your key later in Settings → API Keys.
|
|
||||||
</p>
|
|
||||||
<p style={{ marginTop: 8 }}>
|
<p style={{ marginTop: 8 }}>
|
||||||
<a
|
<a
|
||||||
href="https://platform.openai.com/api-keys"
|
href="https://platform.openai.com/api-keys"
|
||||||
@@ -153,43 +97,33 @@ const sectionDescStyle: CSSProperties = {
|
|||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const codeStyle: CSSProperties = {
|
||||||
|
background: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: '#22c55e',
|
||||||
|
};
|
||||||
|
|
||||||
|
const codeBlockStyle: CSSProperties = {
|
||||||
|
background: 'rgba(0, 0, 0, 0.4)',
|
||||||
|
border: '1px solid #333',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '12px 14px',
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
marginBottom: 12,
|
||||||
|
overflowX: 'auto',
|
||||||
|
};
|
||||||
|
|
||||||
const costNoteStyle: CSSProperties = {
|
const costNoteStyle: CSSProperties = {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: '#6b7280',
|
color: '#6b7280',
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputSectionStyle: CSSProperties = {
|
|
||||||
marginBottom: 20,
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputStyle: CSSProperties = {
|
|
||||||
width: '100%',
|
|
||||||
padding: '12px 14px',
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
background: 'rgba(0, 0, 0, 0.4)',
|
|
||||||
border: '1px solid #333',
|
|
||||||
borderRadius: 8,
|
|
||||||
color: '#fff',
|
|
||||||
outline: 'none',
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorStyle: CSSProperties = {
|
|
||||||
marginTop: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#ef4444',
|
|
||||||
};
|
|
||||||
|
|
||||||
const successStyle: CSSProperties = {
|
|
||||||
marginTop: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#22c55e',
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttonSectionStyle: CSSProperties = {
|
const buttonSectionStyle: CSSProperties = {
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 12,
|
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -205,18 +139,6 @@ const primaryButtonStyle: CSSProperties = {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
};
|
};
|
||||||
|
|
||||||
const skipButtonStyle: CSSProperties = {
|
|
||||||
width: '100%',
|
|
||||||
padding: '12px 16px',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 500,
|
|
||||||
background: 'transparent',
|
|
||||||
color: '#6b7280',
|
|
||||||
border: '1px solid #333',
|
|
||||||
borderRadius: 8,
|
|
||||||
cursor: 'pointer',
|
|
||||||
};
|
|
||||||
|
|
||||||
const noteStyle: CSSProperties = {
|
const noteStyle: CSSProperties = {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: '#6b7280',
|
color: '#6b7280',
|
||||||
|
|||||||
@@ -1,50 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, type CSSProperties } from 'react';
|
import { useState, useEffect, type CSSProperties } from 'react';
|
||||||
import { apiKeyService, ApiKeyStatus } from '@/services/storage/apiKeys';
|
|
||||||
|
|
||||||
export default function ApiKeysViewer() {
|
export default function ApiKeysViewer() {
|
||||||
const [openaiKey, setOpenaiKey] = useState('');
|
const [status, setStatus] = useState<'checking' | 'configured' | 'not-set'>('checking');
|
||||||
const [status, setStatus] = useState<ApiKeyStatus>({ openai: 'not-set' });
|
|
||||||
const [showKey, setShowKey] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (apiKeyService.hasOpenAiKey()) {
|
// Check via health endpoint (server-side check of process.env)
|
||||||
setOpenaiKey(apiKeyService.getMaskedOpenAiKey());
|
fetch('/api/health')
|
||||||
}
|
.then(res => res.json())
|
||||||
setStatus(apiKeyService.getStatus());
|
.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 (
|
return (
|
||||||
<div style={containerStyle}>
|
<div style={containerStyle}>
|
||||||
{/* Features explanation */}
|
{/* Features explanation */}
|
||||||
@@ -56,57 +26,34 @@ export default function ApiKeysViewer() {
|
|||||||
<li>Semantic search via embeddings</li>
|
<li>Semantic search via embeddings</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div style={noteStyle}>
|
<div style={noteStyle}>
|
||||||
Without a key, you can still create and organize nodes manually.
|
Without a key, you can still create and organise nodes manually.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key input */}
|
{/* Status */}
|
||||||
<div style={cardStyle}>
|
<div style={cardStyle}>
|
||||||
<div style={cardHeaderStyle}>
|
<div style={cardHeaderStyle}>
|
||||||
<span style={cardTitleStyle}>OpenAI API Key</span>
|
<span style={cardTitleStyle}>OpenAI API Key</span>
|
||||||
<span style={{ fontSize: 12, color: statusInfo.color }}>
|
<span style={{
|
||||||
{statusInfo.text}
|
fontSize: 12,
|
||||||
|
color: status === 'configured' ? '#22c55e' :
|
||||||
|
status === 'checking' ? '#6b7280' : '#ef4444'
|
||||||
|
}}>
|
||||||
|
{status === 'configured' ? 'Configured' :
|
||||||
|
status === 'checking' ? 'Checking...' : 'Not configured'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<div style={instructionsStyle}>
|
||||||
type={showKey ? 'text' : 'password'}
|
<p style={{ margin: 0, marginBottom: 8 }}>
|
||||||
value={openaiKey}
|
Add your key to <code style={codeInlineStyle}>.env.local</code> in the project root:
|
||||||
onChange={(e) => setOpenaiKey(e.target.value)}
|
</p>
|
||||||
placeholder="sk-..."
|
<div style={codeBlockStyle}>
|
||||||
style={inputStyle}
|
<code>OPENAI_API_KEY=sk-your-key-here</code>
|
||||||
/>
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: 12, color: '#6b7280' }}>
|
||||||
<div style={buttonRowStyle}>
|
Restart the app after changing the key.
|
||||||
<button
|
</p>
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!openaiKey.trim() || openaiKey.includes('•')}
|
|
||||||
style={{
|
|
||||||
...btnPrimaryStyle,
|
|
||||||
opacity: openaiKey.trim() && !openaiKey.includes('•') ? 1 : 0.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save & Test
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleClear}
|
|
||||||
disabled={status.openai === 'not-set'}
|
|
||||||
style={{
|
|
||||||
...btnSecondaryStyle,
|
|
||||||
opacity: status.openai !== 'not-set' ? 1 : 0.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
<label style={toggleStyle}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showKey}
|
|
||||||
onChange={(e) => setShowKey(e.target.checked)}
|
|
||||||
style={{ marginRight: 6 }}
|
|
||||||
/>
|
|
||||||
Show
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -182,54 +129,30 @@ const cardTitleStyle: CSSProperties = {
|
|||||||
color: '#e5e7eb',
|
color: '#e5e7eb',
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyle: CSSProperties = {
|
const instructionsStyle: CSSProperties = {
|
||||||
width: '100%',
|
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',
|
padding: '10px 12px',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
background: 'rgba(0, 0, 0, 0.3)',
|
|
||||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
|
||||||
borderRadius: 6,
|
|
||||||
color: '#e5e7eb',
|
color: '#e5e7eb',
|
||||||
marginBottom: 12,
|
marginBottom: 8,
|
||||||
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',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const helpStyle: CSSProperties = {
|
const helpStyle: CSSProperties = {
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import DatabaseViewer from './DatabaseViewer';
|
|||||||
import ExternalAgentsPanel from './ExternalAgentsPanel';
|
import ExternalAgentsPanel from './ExternalAgentsPanel';
|
||||||
import ContextViewer from './ContextViewer';
|
import ContextViewer from './ContextViewer';
|
||||||
import GuidesViewer from './GuidesViewer';
|
import GuidesViewer from './GuidesViewer';
|
||||||
import { apiKeyService } from '@/services/storage/apiKeys';
|
|
||||||
|
|
||||||
export type SettingsTab =
|
export type SettingsTab =
|
||||||
| 'logs'
|
| 'logs'
|
||||||
| 'tools'
|
| 'tools'
|
||||||
@@ -29,16 +27,7 @@ interface SettingsModalProps {
|
|||||||
type TabType = SettingsTab;
|
type TabType = SettingsTab;
|
||||||
|
|
||||||
export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProps) {
|
export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProps) {
|
||||||
// Default to API Keys tab if no keys are configured, otherwise logs
|
const [activeTab, setActiveTab] = useState<TabType>('logs');
|
||||||
const getDefaultTab = (): TabType => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const hasKey = apiKeyService.getOpenAiKey();
|
|
||||||
return hasKey ? 'logs' : 'apikeys';
|
|
||||||
}
|
|
||||||
return 'logs';
|
|
||||||
};
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(getDefaultTab());
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { openai as openaiProvider } from '@ai-sdk/openai';
|
import { openai as openaiProvider } from '@ai-sdk/openai';
|
||||||
import { generateText } from 'ai';
|
import { generateText } from 'ai';
|
||||||
|
import { hasValidOpenAiKey } from '../storage/apiKeys';
|
||||||
|
|
||||||
export interface DescriptionInput {
|
export interface DescriptionInput {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -15,16 +16,8 @@ export interface DescriptionInput {
|
|||||||
dimensions?: string[];
|
dimensions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Re-export for backwards compatibility — canonical source is ../storage/apiKeys
|
||||||
* Check if we have a valid OpenAI API key configured.
|
export { hasValidOpenAiKey } from '../storage/apiKeys';
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a simple fallback description without AI.
|
* Generate a simple fallback description without AI.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getSQLiteClient } from './sqlite-client';
|
import { getSQLiteClient } from './sqlite-client';
|
||||||
import { openai as openaiProvider } from '@ai-sdk/openai';
|
import { openai as openaiProvider } from '@ai-sdk/openai';
|
||||||
import { generateText } from 'ai';
|
import { generateText } from 'ai';
|
||||||
import { hasValidOpenAiKey } from './descriptionService';
|
import { hasValidOpenAiKey } from '../storage/apiKeys';
|
||||||
|
|
||||||
export interface Dimension {
|
export interface Dimension {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { getSQLiteClient } from './sqlite-client';
|
|||||||
import { Edge, EdgeContext, EdgeData, EdgeCreatedVia, NodeConnection, Node } from '@/types/database';
|
import { Edge, EdgeContext, EdgeData, EdgeCreatedVia, NodeConnection, Node } from '@/types/database';
|
||||||
import { eventBroadcaster } from '../events';
|
import { eventBroadcaster } from '../events';
|
||||||
import { nodeService } from './nodes';
|
import { nodeService } from './nodes';
|
||||||
import { apiKeyService } from '../storage/apiKeys';
|
|
||||||
import { generateText } from 'ai';
|
import { generateText } from 'ai';
|
||||||
import { createOpenAI } from '@ai-sdk/openai';
|
import { createOpenAI } from '@ai-sdk/openai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -54,7 +53,7 @@ async function inferEdgeContext(params: {
|
|||||||
|
|
||||||
// If no API key is configured, degrade gracefully.
|
// If no API key is configured, degrade gracefully.
|
||||||
// We still enforce explanation, but fall back to "related_to" classification.
|
// We still enforce explanation, but fall back to "related_to" classification.
|
||||||
const apiKey = apiKeyService.getOpenAiKey();
|
const apiKey = process.env.OPENAI_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return { type: 'related_to', confidence: 0.0, swap_direction: false };
|
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 }> {
|
}): Promise<{ explanation: string; type: EdgeContext['type']; confidence: number; swap_direction: boolean }> {
|
||||||
const { fromNode, toNode } = params;
|
const { fromNode, toNode } = params;
|
||||||
|
|
||||||
const apiKey = apiKeyService.getOpenAiKey();
|
const apiKey = process.env.OPENAI_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
// Fallback without AI
|
// Fallback without AI
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import { apiKeyService } from './storage/apiKeys';
|
|
||||||
|
|
||||||
// Initialize OpenAI client with dynamic API key support
|
|
||||||
function getOpenAiClient(): OpenAI {
|
function getOpenAiClient(): OpenAI {
|
||||||
const apiKey = apiKeyService.getOpenAiKey();
|
const apiKey = process.env.OPENAI_API_KEY;
|
||||||
if (!apiKey) {
|
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 });
|
return new OpenAI({ apiKey });
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-141
@@ -1,148 +1,34 @@
|
|||||||
// API Key Storage Service
|
// API Key helpers
|
||||||
// Handles storage and retrieval of OpenAI API key
|
// 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';
|
* 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 class ApiKeyService {
|
/**
|
||||||
private static instance: ApiKeyService;
|
* Check if first run (no API key configured)
|
||||||
private openaiKey: string | undefined;
|
*/
|
||||||
private status: ApiKeyStatus = {
|
export function isFirstRun(): boolean {
|
||||||
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<boolean> {
|
|
||||||
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;
|
if (typeof window === 'undefined') return false;
|
||||||
return !localStorage.getItem(FIRST_RUN_KEY);
|
return !localStorage.getItem('ra-h-first-run-complete');
|
||||||
}
|
|
||||||
|
|
||||||
markFirstRunComplete(): void {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem(FIRST_RUN_KEY, 'true');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
export function markFirstRunComplete(): void {
|
||||||
export const apiKeyService = ApiKeyService.getInstance();
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('ra-h-first-run-complete', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user