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!
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
,
+ 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 */}
- {/* 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');
+ }
}
}