diff --git a/.env.example b/.env.example index 4374db3..4ca924b 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,16 @@ -# Database Configuration - SQLite (production ready) -# Paths use sensible defaults if not specified -# SQLITE_DB_PATH=$HOME/Library/Application Support/RA-H/db/rah.sqlite +# RA-H OS Configuration +# Copy to .env.local: cp .env.example .env.local + +# OpenAI API Key (optional) +# Enables: auto-descriptions, smart tagging, semantic search +# Get one at: https://platform.openai.com/api-keys +OPENAI_API_KEY= + +# Database path (defaults work for most users) +# SQLITE_DB_PATH=~/Library/Application Support/RA-H/db/rah.sqlite SQLITE_VEC_EXTENSION_PATH=./vendor/sqlite-extensions/vec0.dylib -# AI API Keys (main orchestrator + mini agents) -ANTHROPIC_API_KEY=your-anthropic-api-key-here -OPENAI_API_KEY=your-openai-api-key-here - -# Optional overrides specifically for ra-h orchestration/delegation -RAH_ORCHESTRATOR_ANTHROPIC_API_KEY= -RAH_DELEGATE_OPENAI_API_KEY= - -# Voice TTS defaults -RAH_TTS_MODEL=gpt-4o-mini-tts -RAH_TTS_VOICE=ash -RAH_TTS_COST_PER_1K_CHAR_USD=0.015 - -# Application Configuration +# App config (no changes needed) NODE_ENV=development PORT=3000 NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/README.md b/README.md index 5a50638..7de05eb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RA-OS +# RA-H OS ``` ██████╗ █████╗ ██╗ ██╗ @@ -9,45 +9,78 @@ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ``` -A lightweight local knowledge graph UI with MCP server. Connect your AI coding agents to a personal knowledge base. BYO API keys, no cloud dependencies. +A local SQLite database with a UI for storing knowledge, and an MCP server so your AI tools can read/write to it. -## What is RA-OS? +**Full documentation:** [ra-h.com/docs](https://ra-h.com/docs) -RA-OS is a stripped-down version of RA-H focused on being a **knowledge management backend for AI agents**. It provides: +--- -- **2-panel UI** – Nodes list + focus panel for viewing/editing knowledge -- **SQLite + sqlite-vec** – Local vector database with semantic search -- **MCP Server** – Connect Claude Code, Cursor, or any MCP-compatible AI assistant +## What This Does -**What's removed:** Built-in chat agents, voice features, delegation system. RA-OS is designed for technical users who want to bring their own AI agents via MCP. +1. **Stores knowledge locally** — Notes, bookmarks, ideas, research in a SQLite database on your machine +2. **Provides a UI** — Browse, search, and organize your nodes at `localhost:3000` +3. **Exposes an MCP server** — Claude Code, Cursor, or any MCP client can query and add to your knowledge base -## Platform Support +Your data stays on your machine. Nothing is sent anywhere unless you configure an API key. -| Platform | Status | -|----------|--------| -| macOS (Apple Silicon) | Supported | -| macOS (Intel) | Supported | -| Linux | Requires manual sqlite-vec build | -| Windows | Requires manual sqlite-vec build | +--- -## Quick Start +## Requirements + +- **Node.js 18+** — [nodejs.org](https://nodejs.org/) +- **macOS** — Works out of the box +- **Linux/Windows** — Requires building sqlite-vec manually (see below) + +--- + +## Install ```bash git clone https://github.com/bradwmorris/ra-h_os.git cd ra-h_os npm install npm rebuild better-sqlite3 -scripts/dev/bootstrap-local.sh +./scripts/dev/bootstrap-local.sh npm run dev ``` -Open http://localhost:3000 → **Settings → API Keys** → add your OpenAI key (for embeddings). +Open [localhost:3000](http://localhost:3000). Done. -## Connecting AI Agents via MCP +--- -RA-OS includes an MCP server that lets Claude Code, Cursor, and other AI assistants read/write your knowledge graph. +## OpenAI API Key -### Quick Setup (Recommended) +**Optional but recommended.** Without a key, you can still create and organize nodes manually. + +With a key, you get: +- Auto-generated descriptions when you add nodes +- Automatic dimension/tag assignment +- Semantic search (find similar content, not just keyword matches) + +**Cost:** Less than $0.10/day for heavy use. Most users spend $1-2/month. + +**Setup:** The app will prompt you on first launch, or go to Settings → API Keys. + +Get a key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys) + +--- + +## Where Your Data Lives + +``` +~/Library/Application Support/RA-H/db/rah.sqlite # macOS +~/.local/share/RA-H/db/rah.sqlite # Linux +%APPDATA%/RA-H/db/rah.sqlite # Windows +``` + +This is a standard SQLite file. You can: +- Back it up by copying the file +- Query it directly with `sqlite3` or any SQLite tool +- Move it between machines + +--- + +## Connect Claude Code (or other MCP clients) Add to your `~/.claude.json`: @@ -62,102 +95,74 @@ Add to your `~/.claude.json`: } ``` -**That's it.** Works without RA-OS running. Requires Node.js 18+. +Restart Claude Code. Your agent can now use these tools: -### Alternative: Local Development - -If you're developing RA-OS and want to use the local server: - -```json -{ - "mcpServers": { - "ra-h": { - "command": "node", - "args": ["/path/to/ra-h_os/apps/mcp-server-standalone/index.js"] - } - } -} -``` - -First install dependencies: `cd apps/mcp-server-standalone && npm install` - -### Available MCP Tools - -| Tool | Description | -|------|-------------| -| `rah_add_node` | Create a new knowledge node | -| `rah_search_nodes` | Search nodes by text | -| `rah_update_node` | Update an existing node | -| `rah_get_nodes` | Get nodes by ID | -| `rah_create_edge` | Connect two nodes | +| Tool | What it does | +|------|--------------| +| `rah_search_nodes` | Find nodes by keyword | +| `rah_add_node` | Create a new node | +| `rah_get_nodes` | Fetch nodes by ID | +| `rah_update_node` | Edit an existing node | +| `rah_create_edge` | Link two nodes together | | `rah_query_edges` | Find connections | -| `rah_list_dimensions` | List all dimensions | -| `rah_create_dimension` | Create a tag/category | -| `rah_update_dimension` | Update a dimension | -| `rah_delete_dimension` | Delete a dimension | +| `rah_list_dimensions` | List all tags/categories | -### HTTP MCP Server (Real-time UI updates) +**Example prompts for Claude Code:** +- "Search my knowledge base for notes about React performance" +- "Add a node about the article I just read on transformers" +- "What nodes are connected to my 'project-ideas' dimension?" -If you want the RA-OS UI to update in real-time when nodes are created: +--- -1. Start RA-OS: `npm run dev` -2. Use HTTP config: +## Direct Database Access -```json -{ - "mcpServers": { - "ra-h": { - "url": "http://127.0.0.1:44145/mcp" - } - } -} +Query your database directly: + +```bash +# Open the database +sqlite3 ~/Library/Application\ Support/RA-H/db/rah.sqlite + +# List all nodes +SELECT id, title, created_at FROM nodes ORDER BY created_at DESC LIMIT 10; + +# Search by title +SELECT title, description FROM nodes WHERE title LIKE '%react%'; + +# Find connections +SELECT n1.title, e.explanation, n2.title +FROM edges e +JOIN nodes n1 ON e.from_node_id = n1.id +JOIN nodes n2 ON e.to_node_id = n2.id +LIMIT 10; ``` -## Project Layout +See [ra-h.com/docs/schema](https://ra-h.com/docs/schema) for full schema documentation. -``` -app/ Next.js App Router -src/ - components/ UI components - services/ Database, embeddings, workflows - tools/ Available tools for workflows -apps/mcp-server/ MCP server (stdio + HTTP) -docs/ Local documentation -scripts/ Dev helpers -vendor/ Pre-built binaries (sqlite-vec) -``` +--- ## Commands -| Command | Description | -|---------|-------------| -| `npm run dev` | Dev server at localhost:3000 | +| Command | What it does | +|---------|--------------| +| `npm run dev` | Start the app at localhost:3000 | | `npm run build` | Production build | -| `npm run type-check` | TypeScript validation | -| `npm run sqlite:backup` | Database snapshot | -| `npm run sqlite:restore` | Restore from backup | +| `npm run type-check` | Check TypeScript | -## Documentation +--- -- [docs/0_overview.md](docs/0_overview.md) – System overview -- [docs/2_schema.md](docs/2_schema.md) – Database schema -- [docs/8_mcp.md](docs/8_mcp.md) – MCP server details +## Linux/Windows -## Linux/Windows Setup +The bundled sqlite-vec binary only works on macOS. For other platforms: -The bundled `sqlite-vec` binary is macOS-only. For other platforms: +1. Build sqlite-vec from [github.com/asg017/sqlite-vec](https://github.com/asg017/sqlite-vec) +2. Place at `vendor/sqlite-extensions/vec0.so` (Linux) or `vec0.dll` (Windows) -1. Clone https://github.com/asg017/sqlite-vec -2. Build for your platform -3. Place at `vendor/sqlite-extensions/vec0.so` (Linux) or `vec0.dll` (Windows) -4. Set `SQLITE_VEC_EXTENSION_PATH` in `.env.local` +Without sqlite-vec, everything works except semantic/vector search. -Without sqlite-vec: UI, node CRUD, and basic search still work. Vector/semantic search requires it. +--- -## Contributing +## More -See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and PRs welcome. - -## License - -[MIT](LICENSE) +- **Full docs:** [ra-h.com/docs](https://ra-h.com/docs) +- **Issues:** [github.com/bradwmorris/ra-h_os/issues](https://github.com/bradwmorris/ra-h_os/issues) +- **License:** MIT diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..57d3124 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,38 @@ +/** + * Main health check endpoint + * Returns overall system health status for quick verification + */ + +import { NextResponse } from 'next/server'; +import { checkDatabaseHealth } from '@/services/database'; +import { hasValidOpenAiKey } from '@/services/database/descriptionService'; + +export const runtime = 'nodejs'; + +export async function GET() { + try { + const dbHealth = await checkDatabaseHealth(); + + const response = { + status: dbHealth.connected ? 'ok' : 'degraded', + database: dbHealth.connected ? 'connected' : 'disconnected', + vectorSearch: dbHealth.vectorExtension ? 'enabled' : 'disabled', + aiFeatures: hasValidOpenAiKey() ? 'enabled' : 'disabled (no API key)', + timestamp: new Date().toISOString(), + }; + + return NextResponse.json(response, { + status: dbHealth.connected ? 200 : 503, + }); + } catch (error) { + return NextResponse.json( + { + status: 'error', + database: 'error', + error: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date().toISOString(), + }, + { status: 500 } + ); + } +} diff --git a/app/api/local/test-anthropic/route.ts b/app/api/local/test-anthropic/route.ts deleted file mode 100644 index cb46927..0000000 --- a/app/api/local/test-anthropic/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -export async function POST(request: NextRequest) { - try { - const { apiKey } = await request.json(); - if (typeof apiKey !== 'string' || apiKey.trim().length === 0) { - return NextResponse.json( - { ok: false, error: 'API key is required.' }, - { status: 400 } - ); - } - - const response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'x-api-key': apiKey.trim(), - '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' }], - }), - }); - - if (!response.ok) { - const errorBody = await response.text(); - return NextResponse.json( - { - ok: false, - status: response.status, - error: errorBody || 'Anthropic returned an error.', - }, - { status: 200 } - ); - } - - return NextResponse.json({ ok: true }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - return NextResponse.json( - { ok: false, error: message }, - { status: 500 } - ); - } -} diff --git a/app/page.tsx b/app/page.tsx index ab919a7..0dd289c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,11 @@ import ThreePanelLayout from '@/components/layout/ThreePanelLayout'; +import FirstRunModal from '@/components/onboarding/FirstRunModal'; export default function Home() { - return ; + return ( + <> + + + + ); } diff --git a/next.config.js b/next.config.js index d912617..cb4343c 100644 --- a/next.config.js +++ b/next.config.js @@ -6,8 +6,8 @@ const nextConfig = { allowedOrigins: ['localhost:3000'], }, }, - devIndicators: false, - + // devIndicators removed - use default behavior + typescript: { ignoreBuildErrors: false, }, diff --git a/scripts/dev/bootstrap-local.sh b/scripts/dev/bootstrap-local.sh index 9c242d7..4a5ea81 100755 --- a/scripts/dev/bootstrap-local.sh +++ b/scripts/dev/bootstrap-local.sh @@ -11,6 +11,15 @@ log() { echo "[bootstrap-local] $1" } +# Check Node.js version (require 18+) +NODE_VERSION=$(node -v 2>/dev/null | cut -d'v' -f2 | cut -d'.' -f1 || echo "0") +if [ "$NODE_VERSION" -lt 18 ]; then + echo "Error: Node.js 18+ required (found v$NODE_VERSION or not installed)" >&2 + echo "Install from: https://nodejs.org" >&2 + exit 1 +fi +log "Node.js v$NODE_VERSION detected ✓" + if [ ! -f "$ENV_TEMPLATE" ]; then echo "Error: ${ENV_TEMPLATE} not found. Make sure .env.example exists." >&2 exit 1 @@ -53,4 +62,4 @@ fi log "Seeding database schema via sqlite-ensure-app-schema.sh" "$SQLITE_SEED_SCRIPT" "$EXPANDED_DB_PATH" -log "Bootstrap complete. Run 'npm run dev:local' to start the local-first app." +log "Bootstrap complete. Run 'npm run dev' to start the app." diff --git a/src/components/onboarding/FirstRunModal.tsx b/src/components/onboarding/FirstRunModal.tsx new file mode 100644 index 0000000..a03d821 --- /dev/null +++ b/src/components/onboarding/FirstRunModal.tsx @@ -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( +
+
e.stopPropagation()}> + {/* Content */} +
+
+

+ To use automated features (embeddings, auto-organise, smart descriptions), + you'll need an OpenAI API key. +

+

+ Average cost for heavy use is less than $0.10 per 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. +

+

+ + Get an API key from OpenAI → + +

+
+
+
+
, + 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', +}; diff --git a/src/components/settings/ApiKeysViewer.tsx b/src/components/settings/ApiKeysViewer.tsx index 40837df..61a8519 100644 --- a/src/components/settings/ApiKeysViewer.tsx +++ b/src/components/settings/ApiKeysViewer.tsx @@ -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({ openai: 'not-set', anthropic: 'not-set' }); - const [showKeys, setShowKeys] = useState(false); + const [status, setStatus] = useState({ 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 (
-

Keys are stored locally and never shared.

+ {/* Features explanation */} +
+
OpenAI API Key enables:
+
    +
  • Auto-generated descriptions for new nodes
  • +
  • Smart dimension assignment
  • +
  • Semantic search via embeddings
  • +
+
+ Without a key, you can still create and organize nodes manually. +
+
- - - {/* OpenAI */} + {/* Key input */}
- OpenAI - - {getStatusLabel(status.openai).text} + OpenAI API Key + + {statusInfo.text}
+ setOpenaiKey(e.target.value)} placeholder="sk-..." style={inputStyle} /> +
+
- {/* Anthropic */} -
-
- Anthropic - - {getStatusLabel(status.anthropic).text} - -
- setAnthropicKey(e.target.value)} - placeholder="sk-ant-..." - style={inputStyle} - /> -
- - -
-
- - {/* Help */} + {/* Get key link */}
); @@ -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', }; diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 03bcd12..8984376 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -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'; }; diff --git a/src/services/database/descriptionService.ts b/src/services/database/descriptionService.ts index 2a7edcb..583f803 100644 --- a/src/services/database/descriptionService.ts +++ b/src/services/database/descriptionService.ts @@ -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 { + // 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 { + // 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(); diff --git a/src/services/storage/apiKeys.ts b/src/services/storage/apiKeys.ts index ad950f5..e778650 100644 --- a/src/services/storage/apiKeys.ts +++ b/src/services/storage/apiKeys.ts @@ -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 { - 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'); + } } }