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
+11
-17
@@ -1,22 +1,16 @@
|
|||||||
# Database Configuration - SQLite (production ready)
|
# RA-H OS Configuration
|
||||||
# Paths use sensible defaults if not specified
|
# Copy to .env.local: cp .env.example .env.local
|
||||||
# SQLITE_DB_PATH=$HOME/Library/Application Support/RA-H/db/rah.sqlite
|
|
||||||
|
# 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
|
SQLITE_VEC_EXTENSION_PATH=./vendor/sqlite-extensions/vec0.dylib
|
||||||
|
|
||||||
# AI API Keys (main orchestrator + mini agents)
|
# App config (no changes needed)
|
||||||
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
|
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3000
|
PORT=3000
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
|||||||
@@ -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
|
## What This Does
|
||||||
- **SQLite + sqlite-vec** – Local vector database with semantic search
|
|
||||||
- **MCP Server** – Connect Claude Code, Cursor, or any MCP-compatible AI assistant
|
|
||||||
|
|
||||||
**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
|
```bash
|
||||||
git clone https://github.com/bradwmorris/ra-h_os.git
|
git clone https://github.com/bradwmorris/ra-h_os.git
|
||||||
cd ra-h_os
|
cd ra-h_os
|
||||||
npm install
|
npm install
|
||||||
npm rebuild better-sqlite3
|
npm rebuild better-sqlite3
|
||||||
scripts/dev/bootstrap-local.sh
|
./scripts/dev/bootstrap-local.sh
|
||||||
npm run dev
|
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`:
|
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
|
| Tool | What it does |
|
||||||
|
|------|--------------|
|
||||||
If you're developing RA-OS and want to use the local server:
|
| `rah_search_nodes` | Find nodes by keyword |
|
||||||
|
| `rah_add_node` | Create a new node |
|
||||||
```json
|
| `rah_get_nodes` | Fetch nodes by ID |
|
||||||
{
|
| `rah_update_node` | Edit an existing node |
|
||||||
"mcpServers": {
|
| `rah_create_edge` | Link two nodes together |
|
||||||
"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 |
|
|
||||||
| `rah_query_edges` | Find connections |
|
| `rah_query_edges` | Find connections |
|
||||||
| `rah_list_dimensions` | List all dimensions |
|
| `rah_list_dimensions` | List all tags/categories |
|
||||||
| `rah_create_dimension` | Create a tag/category |
|
|
||||||
| `rah_update_dimension` | Update a dimension |
|
|
||||||
| `rah_delete_dimension` | Delete a dimension |
|
|
||||||
|
|
||||||
### 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`
|
## Direct Database Access
|
||||||
2. Use HTTP config:
|
|
||||||
|
|
||||||
```json
|
Query your database directly:
|
||||||
{
|
|
||||||
"mcpServers": {
|
```bash
|
||||||
"ra-h": {
|
# Open the database
|
||||||
"url": "http://127.0.0.1:44145/mcp"
|
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
|
## Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | What it does |
|
||||||
|---------|-------------|
|
|---------|--------------|
|
||||||
| `npm run dev` | Dev server at localhost:3000 |
|
| `npm run dev` | Start the app at localhost:3000 |
|
||||||
| `npm run build` | Production build |
|
| `npm run build` | Production build |
|
||||||
| `npm run type-check` | TypeScript validation |
|
| `npm run type-check` | Check TypeScript |
|
||||||
| `npm run sqlite:backup` | Database snapshot |
|
|
||||||
| `npm run sqlite:restore` | Restore from backup |
|
|
||||||
|
|
||||||
## Documentation
|
---
|
||||||
|
|
||||||
- [docs/0_overview.md](docs/0_overview.md) – System overview
|
## Linux/Windows
|
||||||
- [docs/2_schema.md](docs/2_schema.md) – Database schema
|
|
||||||
- [docs/8_mcp.md](docs/8_mcp.md) – MCP server details
|
|
||||||
|
|
||||||
## 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
|
Without sqlite-vec, everything works except semantic/vector search.
|
||||||
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: UI, node CRUD, and basic search still work. Vector/semantic search requires it.
|
---
|
||||||
|
|
||||||
## Contributing
|
## More
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and PRs welcome.
|
- **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
|
- **License:** MIT
|
||||||
|
|
||||||
[MIT](LICENSE)
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-1
@@ -1,5 +1,11 @@
|
|||||||
import ThreePanelLayout from '@/components/layout/ThreePanelLayout';
|
import ThreePanelLayout from '@/components/layout/ThreePanelLayout';
|
||||||
|
import FirstRunModal from '@/components/onboarding/FirstRunModal';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return <ThreePanelLayout />;
|
return (
|
||||||
|
<>
|
||||||
|
<ThreePanelLayout />
|
||||||
|
<FirstRunModal />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@ const nextConfig = {
|
|||||||
allowedOrigins: ['localhost:3000'],
|
allowedOrigins: ['localhost:3000'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
devIndicators: false,
|
// devIndicators removed - use default behavior
|
||||||
|
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: false,
|
ignoreBuildErrors: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ log() {
|
|||||||
echo "[bootstrap-local] $1"
|
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
|
if [ ! -f "$ENV_TEMPLATE" ]; then
|
||||||
echo "Error: ${ENV_TEMPLATE} not found. Make sure .env.example exists." >&2
|
echo "Error: ${ENV_TEMPLATE} not found. Make sure .env.example exists." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -53,4 +62,4 @@ fi
|
|||||||
log "Seeding database schema via sqlite-ensure-app-schema.sh"
|
log "Seeding database schema via sqlite-ensure-app-schema.sh"
|
||||||
"$SQLITE_SEED_SCRIPT" "$EXPANDED_DB_PATH"
|
"$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."
|
||||||
|
|||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -5,144 +5,121 @@ import { apiKeyService, ApiKeyStatus } from '@/services/storage/apiKeys';
|
|||||||
|
|
||||||
export default function ApiKeysViewer() {
|
export default function ApiKeysViewer() {
|
||||||
const [openaiKey, setOpenaiKey] = useState('');
|
const [openaiKey, setOpenaiKey] = useState('');
|
||||||
const [anthropicKey, setAnthropicKey] = useState('');
|
const [status, setStatus] = useState<ApiKeyStatus>({ openai: 'not-set' });
|
||||||
const [status, setStatus] = useState<ApiKeyStatus>({ openai: 'not-set', anthropic: 'not-set' });
|
const [showKey, setShowKey] = useState(false);
|
||||||
const [showKeys, setShowKeys] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = apiKeyService.getStoredKeys();
|
if (apiKeyService.hasOpenAiKey()) {
|
||||||
setOpenaiKey(stored.openai || '');
|
setOpenaiKey(apiKeyService.getMaskedOpenAiKey());
|
||||||
setAnthropicKey(stored.anthropic || '');
|
}
|
||||||
setStatus(apiKeyService.getStatus());
|
setStatus(apiKeyService.getStatus());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSaveOpenAi = async () => {
|
const handleSave = async () => {
|
||||||
if (!openaiKey.trim()) return;
|
if (!openaiKey.trim() || openaiKey.includes('•')) return;
|
||||||
apiKeyService.setOpenAiKey(openaiKey.trim());
|
try {
|
||||||
setStatus(prev => ({ ...prev, openai: 'testing' }));
|
apiKeyService.setOpenAiKey(openaiKey.trim());
|
||||||
const ok = await apiKeyService.testOpenAiConnection();
|
setStatus({ openai: 'testing' });
|
||||||
setStatus(prev => ({ ...prev, openai: ok ? 'connected' : 'failed' }));
|
const ok = await apiKeyService.testOpenAiConnection();
|
||||||
|
setStatus({ openai: ok ? 'connected' : 'failed' });
|
||||||
|
if (ok) {
|
||||||
|
setOpenaiKey(apiKeyService.getMaskedOpenAiKey());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus({ openai: 'failed' });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveAnthropic = async () => {
|
const handleClear = () => {
|
||||||
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 = () => {
|
|
||||||
apiKeyService.clearOpenAiKey();
|
apiKeyService.clearOpenAiKey();
|
||||||
setOpenaiKey('');
|
setOpenaiKey('');
|
||||||
setStatus(prev => ({ ...prev, openai: 'not-set' }));
|
setStatus({ openai: 'not-set' });
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearAnthropic = () => {
|
|
||||||
apiKeyService.clearAnthropicKey();
|
|
||||||
setAnthropicKey('');
|
|
||||||
setStatus(prev => ({ ...prev, anthropic: 'not-set' }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusLabel = (s: string) => {
|
const getStatusLabel = (s: string) => {
|
||||||
if (s === 'connected') return { text: 'Connected', color: '#22c55e' };
|
if (s === 'connected') return { text: 'Connected', color: '#22c55e' };
|
||||||
if (s === 'failed') return { text: 'Failed', color: '#ef4444' };
|
if (s === 'failed') return { text: 'Failed', color: '#ef4444' };
|
||||||
if (s === 'testing') return { text: 'Testing...', color: '#6b7280' };
|
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 (
|
return (
|
||||||
<div style={containerStyle}>
|
<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}>
|
{/* Key input */}
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showKeys}
|
|
||||||
onChange={(e) => setShowKeys(e.target.checked)}
|
|
||||||
style={{ marginRight: 8 }}
|
|
||||||
/>
|
|
||||||
Show keys
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* OpenAI */}
|
|
||||||
<div style={cardStyle}>
|
<div style={cardStyle}>
|
||||||
<div style={cardHeaderStyle}>
|
<div style={cardHeaderStyle}>
|
||||||
<span style={cardTitleStyle}>OpenAI</span>
|
<span style={cardTitleStyle}>OpenAI API Key</span>
|
||||||
<span style={{ fontSize: 12, color: getStatusLabel(status.openai).color }}>
|
<span style={{ fontSize: 12, color: statusInfo.color }}>
|
||||||
{getStatusLabel(status.openai).text}
|
{statusInfo.text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type={showKeys ? 'text' : 'password'}
|
type={showKey ? 'text' : 'password'}
|
||||||
value={openaiKey}
|
value={openaiKey}
|
||||||
onChange={(e) => setOpenaiKey(e.target.value)}
|
onChange={(e) => setOpenaiKey(e.target.value)}
|
||||||
placeholder="sk-..."
|
placeholder="sk-..."
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={buttonRowStyle}>
|
<div style={buttonRowStyle}>
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveOpenAi}
|
onClick={handleSave}
|
||||||
disabled={!openaiKey.trim()}
|
disabled={!openaiKey.trim() || openaiKey.includes('•')}
|
||||||
style={{ ...btnPrimaryStyle, opacity: openaiKey.trim() ? 1 : 0.4 }}
|
style={{
|
||||||
|
...btnPrimaryStyle,
|
||||||
|
opacity: openaiKey.trim() && !openaiKey.includes('•') ? 1 : 0.4,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Save
|
Save & Test
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleClearOpenAi}
|
onClick={handleClear}
|
||||||
disabled={!openaiKey}
|
disabled={status.openai === 'not-set'}
|
||||||
style={{ ...btnSecondaryStyle, opacity: openaiKey ? 1 : 0.4 }}
|
style={{
|
||||||
|
...btnSecondaryStyle,
|
||||||
|
opacity: status.openai !== 'not-set' ? 1 : 0.4,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
|
<label style={toggleStyle}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showKey}
|
||||||
|
onChange={(e) => setShowKey(e.target.checked)}
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
Show
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Anthropic */}
|
{/* Get key link */}
|
||||||
<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 */}
|
|
||||||
<div style={helpStyle}>
|
<div style={helpStyle}>
|
||||||
<div style={{ marginBottom: 8 }}>Get keys from:</div>
|
<a
|
||||||
<div>
|
href="https://platform.openai.com/api-keys"
|
||||||
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noreferrer" style={linkStyle}>
|
target="_blank"
|
||||||
OpenAI →
|
rel="noreferrer"
|
||||||
</a>
|
style={linkStyle}
|
||||||
<span style={{ margin: '0 12px', color: '#4b5563' }}>·</span>
|
>
|
||||||
<a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noreferrer" style={linkStyle}>
|
Get your API key from OpenAI →
|
||||||
Anthropic →
|
</a>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -154,19 +131,34 @@ const containerStyle: CSSProperties = {
|
|||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
const descStyle: CSSProperties = {
|
const featuresBoxStyle: CSSProperties = {
|
||||||
fontSize: 13,
|
background: 'rgba(34, 197, 94, 0.08)',
|
||||||
color: '#6b7280',
|
border: '1px solid rgba(34, 197, 94, 0.2)',
|
||||||
marginBottom: 16,
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleStyle: CSSProperties = {
|
const featuresHeaderStyle: CSSProperties = {
|
||||||
display: 'flex',
|
fontSize: 13,
|
||||||
alignItems: 'center',
|
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,
|
fontSize: 12,
|
||||||
color: '#6b7280',
|
color: '#6b7280',
|
||||||
cursor: 'pointer',
|
fontStyle: 'italic',
|
||||||
marginBottom: 20,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const cardStyle: CSSProperties = {
|
const cardStyle: CSSProperties = {
|
||||||
@@ -206,6 +198,7 @@ const inputStyle: CSSProperties = {
|
|||||||
const buttonRowStyle: CSSProperties = {
|
const buttonRowStyle: CSSProperties = {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnPrimaryStyle: CSSProperties = {
|
const btnPrimaryStyle: CSSProperties = {
|
||||||
@@ -230,12 +223,16 @@ const btnSecondaryStyle: CSSProperties = {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleStyle: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6b7280',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
};
|
||||||
|
|
||||||
const helpStyle: CSSProperties = {
|
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,
|
fontSize: 12,
|
||||||
color: '#6b7280',
|
color: '#6b7280',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
|||||||
// Default to API Keys tab if no keys are configured, otherwise logs
|
// Default to API Keys tab if no keys are configured, otherwise logs
|
||||||
const getDefaultTab = (): TabType => {
|
const getDefaultTab = (): TabType => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const hasKeys = apiKeyService.getOpenAiKey() || apiKeyService.getAnthropicKey();
|
const hasKey = apiKeyService.getOpenAiKey();
|
||||||
return hasKeys ? 'logs' : 'apikeys';
|
return hasKey ? 'logs' : 'apikeys';
|
||||||
}
|
}
|
||||||
return 'logs';
|
return 'logs';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,11 +15,66 @@ export interface DescriptionInput {
|
|||||||
dimensions?: string[];
|
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.
|
* Generate a 280-character description for a knowledge node.
|
||||||
* Contextually grounded - adapts to node type (person, concept, article, etc.)
|
* 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> {
|
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 {
|
try {
|
||||||
const prompt = buildDescriptionPrompt(input);
|
const prompt = buildDescriptionPrompt(input);
|
||||||
|
|
||||||
@@ -43,7 +98,7 @@ export async function generateDescription(input: DescriptionInput): Promise<stri
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[DescriptionService] Error generating description:', error);
|
console.error('[DescriptionService] Error generating description:', error);
|
||||||
// Return a fallback description
|
// 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 { getSQLiteClient } from './sqlite-client';
|
||||||
import { openai as openaiProvider } from '@ai-sdk/openai';
|
import { openai as openaiProvider } from '@ai-sdk/openai';
|
||||||
import { generateText } from 'ai';
|
import { generateText } from 'ai';
|
||||||
|
import { hasValidOpenAiKey } from './descriptionService';
|
||||||
|
|
||||||
export interface Dimension {
|
export interface Dimension {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -48,6 +49,9 @@ export class DimensionService {
|
|||||||
/**
|
/**
|
||||||
* Automatically assign locked dimensions + suggest keyword dimensions
|
* Automatically assign locked dimensions + suggest keyword dimensions
|
||||||
* Returns { locked: string[], keywords: string[] }
|
* 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: {
|
static async assignDimensions(nodeData: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -55,6 +59,12 @@ export class DimensionService {
|
|||||||
link?: string;
|
link?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}): Promise<{ locked: string[]; keywords: 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 {
|
try {
|
||||||
const lockedDimensions = await this.getLockedDimensions();
|
const lockedDimensions = await this.getLockedDimensions();
|
||||||
|
|
||||||
|
|||||||
+40
-109
@@ -1,24 +1,18 @@
|
|||||||
// API Key Storage Service
|
// API Key Storage Service
|
||||||
// Handles secure storage and retrieval of user-provided API keys
|
// Handles storage and retrieval of OpenAI API key
|
||||||
|
|
||||||
export interface ApiKeys {
|
|
||||||
openai?: string;
|
|
||||||
anthropic?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiKeyStatus {
|
export interface ApiKeyStatus {
|
||||||
openai: 'connected' | 'failed' | 'testing' | 'not-set';
|
openai: 'connected' | 'failed' | 'testing' | 'not-set';
|
||||||
anthropic: 'connected' | 'failed' | 'testing' | 'not-set';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'ra-h-api-keys';
|
const STORAGE_KEY = 'ra-h-api-keys';
|
||||||
|
const FIRST_RUN_KEY = 'ra-h-first-run-complete';
|
||||||
|
|
||||||
export class ApiKeyService {
|
export class ApiKeyService {
|
||||||
private static instance: ApiKeyService;
|
private static instance: ApiKeyService;
|
||||||
private keys: ApiKeys = {};
|
private openaiKey: string | undefined;
|
||||||
private status: ApiKeyStatus = {
|
private status: ApiKeyStatus = {
|
||||||
openai: 'not-set',
|
openai: 'not-set',
|
||||||
anthropic: 'not-set'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static getInstance(): ApiKeyService {
|
static getInstance(): ApiKeyService {
|
||||||
@@ -38,12 +32,13 @@ export class ApiKeyService {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
this.keys = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
|
this.openaiKey = parsed.openai;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to load API keys from storage:', 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 {
|
private saveKeys(): void {
|
||||||
try {
|
try {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.keys));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ openai: this.openaiKey }));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save API keys to storage:', 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)
|
// Get OpenAI API key (user key or fallback to env)
|
||||||
getOpenAiKey(): string | undefined {
|
getOpenAiKey(): string | undefined {
|
||||||
// Priority: User key > Environment key
|
// Priority: User key > Environment key
|
||||||
return this.keys.openai || process.env.OPENAI_API_KEY;
|
return this.openaiKey || 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set OpenAI API key
|
// Set OpenAI API key
|
||||||
setOpenAiKey(key: string): void {
|
setOpenAiKey(key: string): void {
|
||||||
if (this.validateOpenAiKey(key)) {
|
if (this.validateOpenAiKey(key)) {
|
||||||
this.keys.openai = key;
|
this.openaiKey = key;
|
||||||
this.saveKeys();
|
this.saveKeys();
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Invalid OpenAI API key format');
|
throw new Error('Invalid OpenAI API key format');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set Anthropic API key
|
// Clear OpenAI 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
|
|
||||||
clearOpenAiKey(): void {
|
clearOpenAiKey(): void {
|
||||||
delete this.keys.openai;
|
this.openaiKey = undefined;
|
||||||
this.saveKeys();
|
this.saveKeys();
|
||||||
this.status.openai = 'not-set';
|
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)
|
// Get masked key for display (show only last 4 characters)
|
||||||
getMaskedKey(provider: 'openai' | 'anthropic'): string {
|
getMaskedOpenAiKey(): string {
|
||||||
const key = provider === 'openai' ? this.keys.openai : this.keys.anthropic;
|
if (!this.openaiKey) return '';
|
||||||
if (!key) return '';
|
return '••••••••••••••••••••' + this.openaiKey.slice(-4);
|
||||||
return '••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••' + key.slice(-4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has provided custom keys
|
// Check if user has provided a key
|
||||||
hasUserKeys(): boolean {
|
hasOpenAiKey(): boolean {
|
||||||
return !!(this.keys.openai || this.keys.anthropic);
|
return !!this.openaiKey;
|
||||||
}
|
|
||||||
|
|
||||||
// Get current keys (for internal use)
|
|
||||||
getStoredKeys(): ApiKeys {
|
|
||||||
return { ...this.keys };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate OpenAI key format
|
// Validate OpenAI key format
|
||||||
private validateOpenAiKey(key: string): boolean {
|
private validateOpenAiKey(key: string): boolean {
|
||||||
return typeof key === 'string' &&
|
return (
|
||||||
key.length > 20 &&
|
typeof key === 'string' &&
|
||||||
(key.startsWith('sk-') || key.startsWith('sk-proj-'));
|
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-');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test connection to OpenAI
|
// Test connection to OpenAI
|
||||||
@@ -150,13 +102,13 @@ export class ApiKeyService {
|
|||||||
if (!testKey) return false;
|
if (!testKey) return false;
|
||||||
|
|
||||||
this.status.openai = 'testing';
|
this.status.openai = 'testing';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://api.openai.com/v1/models', {
|
const response = await fetch('https://api.openai.com/v1/models', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${testKey}`,
|
Authorization: `Bearer ${testKey}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isConnected = response.ok;
|
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
|
// Get connection status
|
||||||
getStatus(): ApiKeyStatus {
|
getStatus(): ApiKeyStatus {
|
||||||
return { ...this.status };
|
return { ...this.status };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update status
|
// Update status
|
||||||
updateStatus(provider: 'openai' | 'anthropic', status: ApiKeyStatus['openai']): void {
|
updateStatus(status: ApiKeyStatus['openai']): void {
|
||||||
this.status[provider] = status;
|
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