fix: fast node creation, first-run modal, simplified docs

- Skip AI calls when no valid OpenAI key (fixes 9-13s timeout)
- Add first-run modal prompting for API key
- Rewrite README for first-time users
- Add /api/health endpoint
- Remove all Anthropic references (not used in OS)
- Fix bootstrap script (dev:local → dev)
- Fix next.config.js devIndicators warning
- Add Node 18+ version check

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-02-02 15:10:46 +11:00
co-authored by Claude Opus 4.5
parent 38386afea4
commit 7beba57f63
13 changed files with 616 additions and 389 deletions
+229
View File
@@ -0,0 +1,229 @@
"use client";
import { useState, useEffect, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { apiKeyService } 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()) {
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();
setIsOpen(false);
};
if (!isOpen) return null;
return createPortal(
<div style={overlayStyle}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
{/* Content */}
<div style={contentStyle}>
<div style={sectionStyle}>
<p style={sectionDescStyle}>
To use automated features (embeddings, auto-organise, smart descriptions),
you'll need an OpenAI API key.
</p>
<p style={costNoteStyle}>
Average cost for heavy use is less than $0.10 per day.
</p>
</div>
<div style={inputSectionStyle}>
<input
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 style={buttonSectionStyle}>
<button
onClick={handleSaveKey}
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>
</div>
<div style={noteStyle}>
<p>
You can add or change your key later in Settings → API Keys.
</p>
<p style={{ marginTop: 8 }}>
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noreferrer"
style={linkStyle}
>
Get an API key from OpenAI →
</a>
</p>
</div>
</div>
</div>
</div>,
document.body
);
}
const overlayStyle: CSSProperties = {
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.85)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
};
const modalStyle: CSSProperties = {
background: '#141414',
border: '1px solid #262626',
borderRadius: 16,
width: '100%',
maxWidth: 440,
padding: 32,
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)',
};
const contentStyle: CSSProperties = {};
const sectionStyle: CSSProperties = {
marginBottom: 20,
};
const sectionDescStyle: CSSProperties = {
fontSize: 14,
color: '#d1d5db',
marginBottom: 12,
lineHeight: 1.5,
};
const costNoteStyle: CSSProperties = {
fontSize: 13,
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 = {
display: 'flex',
flexDirection: 'column',
gap: 12,
marginBottom: 20,
};
const primaryButtonStyle: CSSProperties = {
width: '100%',
padding: '12px 16px',
fontSize: 14,
fontWeight: 500,
background: '#22c55e',
color: '#052e16',
border: 'none',
borderRadius: 8,
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 = {
fontSize: 12,
color: '#6b7280',
textAlign: 'center',
};
const linkStyle: CSSProperties = {
color: '#22c55e',
textDecoration: 'none',
};
+107 -110
View File
@@ -5,144 +5,121 @@ import { apiKeyService, ApiKeyStatus } from '@/services/storage/apiKeys';
export default function ApiKeysViewer() {
const [openaiKey, setOpenaiKey] = useState('');
const [anthropicKey, setAnthropicKey] = useState('');
const [status, setStatus] = useState<ApiKeyStatus>({ openai: 'not-set', anthropic: 'not-set' });
const [showKeys, setShowKeys] = useState(false);
const [status, setStatus] = useState<ApiKeyStatus>({ openai: 'not-set' });
const [showKey, setShowKey] = useState(false);
useEffect(() => {
const stored = apiKeyService.getStoredKeys();
setOpenaiKey(stored.openai || '');
setAnthropicKey(stored.anthropic || '');
if (apiKeyService.hasOpenAiKey()) {
setOpenaiKey(apiKeyService.getMaskedOpenAiKey());
}
setStatus(apiKeyService.getStatus());
}, []);
const handleSaveOpenAi = async () => {
if (!openaiKey.trim()) return;
apiKeyService.setOpenAiKey(openaiKey.trim());
setStatus(prev => ({ ...prev, openai: 'testing' }));
const ok = await apiKeyService.testOpenAiConnection();
setStatus(prev => ({ ...prev, openai: ok ? 'connected' : 'failed' }));
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 handleSaveAnthropic = async () => {
if (!anthropicKey.trim()) return;
apiKeyService.setAnthropicKey(anthropicKey.trim());
setStatus(prev => ({ ...prev, anthropic: 'testing' }));
const ok = await apiKeyService.testAnthropicConnection();
setStatus(prev => ({ ...prev, anthropic: ok ? 'connected' : 'failed' }));
};
const handleClearOpenAi = () => {
const handleClear = () => {
apiKeyService.clearOpenAiKey();
setOpenaiKey('');
setStatus(prev => ({ ...prev, openai: 'not-set' }));
};
const handleClearAnthropic = () => {
apiKeyService.clearAnthropicKey();
setAnthropicKey('');
setStatus(prev => ({ ...prev, anthropic: 'not-set' }));
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 set', color: '#6b7280' };
return { text: 'Not configured', color: '#6b7280' };
};
const statusInfo = getStatusLabel(status.openai);
return (
<div style={containerStyle}>
<p style={descStyle}>Keys are stored locally and never shared.</p>
{/* Features explanation */}
<div style={featuresBoxStyle}>
<div style={featuresHeaderStyle}>OpenAI API Key enables:</div>
<ul style={featuresListStyle}>
<li>Auto-generated descriptions for new nodes</li>
<li>Smart dimension assignment</li>
<li>Semantic search via embeddings</li>
</ul>
<div style={noteStyle}>
Without a key, you can still create and organize nodes manually.
</div>
</div>
<label style={toggleStyle}>
<input
type="checkbox"
checked={showKeys}
onChange={(e) => setShowKeys(e.target.checked)}
style={{ marginRight: 8 }}
/>
Show keys
</label>
{/* OpenAI */}
{/* Key input */}
<div style={cardStyle}>
<div style={cardHeaderStyle}>
<span style={cardTitleStyle}>OpenAI</span>
<span style={{ fontSize: 12, color: getStatusLabel(status.openai).color }}>
{getStatusLabel(status.openai).text}
<span style={cardTitleStyle}>OpenAI API Key</span>
<span style={{ fontSize: 12, color: statusInfo.color }}>
{statusInfo.text}
</span>
</div>
<input
type={showKeys ? 'text' : 'password'}
type={showKey ? 'text' : 'password'}
value={openaiKey}
onChange={(e) => setOpenaiKey(e.target.value)}
placeholder="sk-..."
style={inputStyle}
/>
<div style={buttonRowStyle}>
<button
onClick={handleSaveOpenAi}
disabled={!openaiKey.trim()}
style={{ ...btnPrimaryStyle, opacity: openaiKey.trim() ? 1 : 0.4 }}
onClick={handleSave}
disabled={!openaiKey.trim() || openaiKey.includes('•')}
style={{
...btnPrimaryStyle,
opacity: openaiKey.trim() && !openaiKey.includes('•') ? 1 : 0.4,
}}
>
Save
Save & Test
</button>
<button
onClick={handleClearOpenAi}
disabled={!openaiKey}
style={{ ...btnSecondaryStyle, opacity: openaiKey ? 1 : 0.4 }}
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>
{/* Anthropic */}
<div style={cardStyle}>
<div style={cardHeaderStyle}>
<span style={cardTitleStyle}>Anthropic</span>
<span style={{ fontSize: 12, color: getStatusLabel(status.anthropic).color }}>
{getStatusLabel(status.anthropic).text}
</span>
</div>
<input
type={showKeys ? 'text' : 'password'}
value={anthropicKey}
onChange={(e) => setAnthropicKey(e.target.value)}
placeholder="sk-ant-..."
style={inputStyle}
/>
<div style={buttonRowStyle}>
<button
onClick={handleSaveAnthropic}
disabled={!anthropicKey.trim()}
style={{ ...btnPrimaryStyle, opacity: anthropicKey.trim() ? 1 : 0.4 }}
>
Save
</button>
<button
onClick={handleClearAnthropic}
disabled={!anthropicKey}
style={{ ...btnSecondaryStyle, opacity: anthropicKey ? 1 : 0.4 }}
>
Clear
</button>
</div>
</div>
{/* Help */}
{/* Get key link */}
<div style={helpStyle}>
<div style={{ marginBottom: 8 }}>Get keys from:</div>
<div>
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noreferrer" style={linkStyle}>
OpenAI
</a>
<span style={{ margin: '0 12px', color: '#4b5563' }}>·</span>
<a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noreferrer" style={linkStyle}>
Anthropic
</a>
</div>
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noreferrer"
style={linkStyle}
>
Get your API key from OpenAI
</a>
</div>
</div>
);
@@ -154,19 +131,34 @@ const containerStyle: CSSProperties = {
overflow: 'auto',
};
const descStyle: CSSProperties = {
fontSize: 13,
color: '#6b7280',
marginBottom: 16,
const featuresBoxStyle: CSSProperties = {
background: 'rgba(34, 197, 94, 0.08)',
border: '1px solid rgba(34, 197, 94, 0.2)',
borderRadius: 8,
padding: 16,
marginBottom: 20,
};
const toggleStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
const featuresHeaderStyle: CSSProperties = {
fontSize: 13,
fontWeight: 500,
color: '#22c55e',
marginBottom: 8,
};
const featuresListStyle: CSSProperties = {
margin: 0,
paddingLeft: 20,
fontSize: 13,
color: '#d1d5db',
lineHeight: 1.6,
};
const noteStyle: CSSProperties = {
marginTop: 12,
fontSize: 12,
color: '#6b7280',
cursor: 'pointer',
marginBottom: 20,
fontStyle: 'italic',
};
const cardStyle: CSSProperties = {
@@ -206,6 +198,7 @@ const inputStyle: CSSProperties = {
const buttonRowStyle: CSSProperties = {
display: 'flex',
gap: 8,
alignItems: 'center',
};
const btnPrimaryStyle: CSSProperties = {
@@ -230,12 +223,16 @@ const btnSecondaryStyle: CSSProperties = {
cursor: 'pointer',
};
const toggleStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
fontSize: 12,
color: '#6b7280',
cursor: 'pointer',
marginLeft: 'auto',
};
const helpStyle: CSSProperties = {
marginTop: 8,
padding: 16,
background: 'rgba(255, 255, 255, 0.02)',
border: '1px solid rgba(255, 255, 255, 0.06)',
borderRadius: 8,
fontSize: 12,
color: '#6b7280',
};
+2 -2
View File
@@ -32,8 +32,8 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
// Default to API Keys tab if no keys are configured, otherwise logs
const getDefaultTab = (): TabType => {
if (typeof window !== 'undefined') {
const hasKeys = apiKeyService.getOpenAiKey() || apiKeyService.getAnthropicKey();
return hasKeys ? 'logs' : 'apikeys';
const hasKey = apiKeyService.getOpenAiKey();
return hasKey ? 'logs' : 'apikeys';
}
return 'logs';
};
+56 -1
View File
@@ -15,11 +15,66 @@ 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;
}
/**
* Generate a simple fallback description without AI.
* Used when no API key is available or for simple inputs.
*/
export function generateFallbackDescription(input: DescriptionInput): string {
const { title, type, metadata, dimensions } = input;
// Build a contextual fallback
const parts: string[] = [];
if (metadata?.author || metadata?.channel_name) {
parts.push(`By ${metadata.author || metadata.channel_name}`);
}
if (type) {
parts.push(type.charAt(0).toUpperCase() + type.slice(1));
}
if (dimensions?.length) {
parts.push(`in ${dimensions.slice(0, 2).join(', ')}`);
}
if (parts.length > 0) {
return `${parts.join(' — ')}: ${title.slice(0, 200)}`;
}
return `Knowledge item: ${title.slice(0, 250)}`;
}
/**
* Generate a 280-character description for a knowledge node.
* Contextually grounded - adapts to node type (person, concept, article, etc.)
*
* IMPORTANT: Returns fallback immediately if no valid API key is configured.
* This prevents slow node creation (9-13s timeout) when OpenAI is unavailable.
*/
export async function generateDescription(input: DescriptionInput): Promise<string> {
// Fast path: skip AI if no valid API key
if (!hasValidOpenAiKey()) {
console.log(`[DescriptionService] No valid OpenAI key, using fallback for: "${input.title}"`);
return generateFallbackDescription(input);
}
// Fast path: skip AI for very short inputs (likely just notes)
if (!input.content && !input.link && input.title.length < 30) {
console.log(`[DescriptionService] Short input, using fallback for: "${input.title}"`);
return generateFallbackDescription(input);
}
try {
const prompt = buildDescriptionPrompt(input);
@@ -43,7 +98,7 @@ export async function generateDescription(input: DescriptionInput): Promise<stri
} catch (error) {
console.error('[DescriptionService] Error generating description:', error);
// Return a fallback description
return `This is a ${input.type || 'knowledge item'} titled "${input.title.slice(0, 200)}".`;
return generateFallbackDescription(input);
}
}
+10
View File
@@ -1,6 +1,7 @@
import { getSQLiteClient } from './sqlite-client';
import { openai as openaiProvider } from '@ai-sdk/openai';
import { generateText } from 'ai';
import { hasValidOpenAiKey } from './descriptionService';
export interface Dimension {
name: string;
@@ -48,6 +49,9 @@ export class DimensionService {
/**
* Automatically assign locked dimensions + suggest keyword dimensions
* Returns { locked: string[], keywords: string[] }
*
* IMPORTANT: Returns empty result immediately if no valid API key is configured.
* This prevents slow node creation when OpenAI is unavailable.
*/
static async assignDimensions(nodeData: {
title: string;
@@ -55,6 +59,12 @@ export class DimensionService {
link?: string;
description?: string;
}): Promise<{ locked: string[]; keywords: string[] }> {
// Fast path: skip AI if no valid API key
if (!hasValidOpenAiKey()) {
console.log(`[DimensionAssignment] No valid OpenAI key, skipping for: "${nodeData.title}"`);
return { locked: [], keywords: [] };
}
try {
const lockedDimensions = await this.getLockedDimensions();
+40 -109
View File
@@ -1,24 +1,18 @@
// API Key Storage Service
// Handles secure storage and retrieval of user-provided API keys
export interface ApiKeys {
openai?: string;
anthropic?: string;
}
// Handles storage and retrieval of OpenAI API key
export interface ApiKeyStatus {
openai: 'connected' | 'failed' | 'testing' | 'not-set';
anthropic: 'connected' | 'failed' | 'testing' | 'not-set';
}
const STORAGE_KEY = 'ra-h-api-keys';
const FIRST_RUN_KEY = 'ra-h-first-run-complete';
export class ApiKeyService {
private static instance: ApiKeyService;
private keys: ApiKeys = {};
private openaiKey: string | undefined;
private status: ApiKeyStatus = {
openai: 'not-set',
anthropic: 'not-set'
};
static getInstance(): ApiKeyService {
@@ -38,12 +32,13 @@ export class ApiKeyService {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
this.keys = JSON.parse(stored);
const parsed = JSON.parse(stored);
this.openaiKey = parsed.openai;
}
}
} catch (error) {
console.warn('Failed to load API keys from storage:', error);
this.keys = {};
this.openaiKey = undefined;
}
}
@@ -51,7 +46,7 @@ export class ApiKeyService {
private saveKeys(): void {
try {
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.keys));
localStorage.setItem(STORAGE_KEY, JSON.stringify({ openai: this.openaiKey }));
}
} catch (error) {
console.error('Failed to save API keys to storage:', error);
@@ -61,87 +56,44 @@ export class ApiKeyService {
// Get OpenAI API key (user key or fallback to env)
getOpenAiKey(): string | undefined {
// Priority: User key > Environment key
return this.keys.openai || process.env.OPENAI_API_KEY;
}
// Get Anthropic API key (user key or fallback to env)
getAnthropicKey(): string | undefined {
// Priority: User key > Environment key
return this.keys.anthropic || process.env.ANTHROPIC_API_KEY;
return this.openaiKey || process.env.OPENAI_API_KEY;
}
// Set OpenAI API key
setOpenAiKey(key: string): void {
if (this.validateOpenAiKey(key)) {
this.keys.openai = key;
this.openaiKey = key;
this.saveKeys();
} else {
throw new Error('Invalid OpenAI API key format');
}
}
// Set Anthropic API key
setAnthropicKey(key: string): void {
if (this.validateAnthropicKey(key)) {
this.keys.anthropic = key;
this.saveKeys();
} else {
throw new Error('Invalid Anthropic API key format');
}
}
// Clear specific key
// Clear OpenAI key
clearOpenAiKey(): void {
delete this.keys.openai;
this.openaiKey = undefined;
this.saveKeys();
this.status.openai = 'not-set';
}
clearAnthropicKey(): void {
delete this.keys.anthropic;
this.saveKeys();
this.status.anthropic = 'not-set';
}
// Clear all keys
clearAllKeys(): void {
this.keys = {};
this.saveKeys();
this.status = {
openai: 'not-set',
anthropic: 'not-set'
};
}
// Get masked key for display (show only last 4 characters)
getMaskedKey(provider: 'openai' | 'anthropic'): string {
const key = provider === 'openai' ? this.keys.openai : this.keys.anthropic;
if (!key) return '';
return '••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••' + key.slice(-4);
getMaskedOpenAiKey(): string {
if (!this.openaiKey) return '';
return '••••••••••••••••••••' + this.openaiKey.slice(-4);
}
// Check if user has provided custom keys
hasUserKeys(): boolean {
return !!(this.keys.openai || this.keys.anthropic);
}
// Get current keys (for internal use)
getStoredKeys(): ApiKeys {
return { ...this.keys };
// 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-'));
}
// Validate Anthropic key format
private validateAnthropicKey(key: string): boolean {
return typeof key === 'string' &&
key.length > 20 &&
key.startsWith('sk-ant-');
return (
typeof key === 'string' &&
key.length > 20 &&
(key.startsWith('sk-') || key.startsWith('sk-proj-'))
);
}
// Test connection to OpenAI
@@ -150,13 +102,13 @@ export class ApiKeyService {
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'
}
Authorization: `Bearer ${testKey}`,
'Content-Type': 'application/json',
},
});
const isConnected = response.ok;
@@ -169,47 +121,26 @@ export class ApiKeyService {
}
}
// Test connection to Anthropic
async testAnthropicConnection(key?: string): Promise<boolean> {
const testKey = key || this.getAnthropicKey();
if (!testKey) return false;
this.status.anthropic = 'testing';
try {
// Simple test call to Anthropic API
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': testKey,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: 'claude-3-haiku-20240307',
max_tokens: 1,
messages: [{ role: 'user', content: 'test' }]
})
});
const isConnected = response.ok;
this.status.anthropic = isConnected ? 'connected' : 'failed';
return isConnected;
} catch (error) {
console.error('Anthropic connection test failed:', error);
this.status.anthropic = 'failed';
return false;
}
}
// Get connection status
getStatus(): ApiKeyStatus {
return { ...this.status };
}
// Update status
updateStatus(provider: 'openai' | 'anthropic', status: ApiKeyStatus['openai']): void {
this.status[provider] = 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');
}
}
}