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:
co-authored by
Claude Opus 4.5
parent
38386afea4
commit
7beba57f63
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user