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)
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 FirstRunModal from '@/components/onboarding/FirstRunModal';
|
||||
|
||||
export default function Home() {
|
||||
return <ThreePanelLayout />;
|
||||
return (
|
||||
<>
|
||||
<ThreePanelLayout />
|
||||
<FirstRunModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ const nextConfig = {
|
||||
allowedOrigins: ['localhost:3000'],
|
||||
},
|
||||
},
|
||||
devIndicators: false,
|
||||
// devIndicators removed - use default behavior
|
||||
|
||||
typescript: {
|
||||
ignoreBuildErrors: false,
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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() {
|
||||
const [openaiKey, setOpenaiKey] = useState('');
|
||||
const [anthropicKey, setAnthropicKey] = useState('');
|
||||
const [status, setStatus] = useState<ApiKeyStatus>({ openai: 'not-set', anthropic: 'not-set' });
|
||||
const [showKeys, setShowKeys] = useState(false);
|
||||
const [status, setStatus] = useState<ApiKeyStatus>({ openai: 'not-set' });
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = apiKeyService.getStoredKeys();
|
||||
setOpenaiKey(stored.openai || '');
|
||||
setAnthropicKey(stored.anthropic || '');
|
||||
if (apiKeyService.hasOpenAiKey()) {
|
||||
setOpenaiKey(apiKeyService.getMaskedOpenAiKey());
|
||||
}
|
||||
setStatus(apiKeyService.getStatus());
|
||||
}, []);
|
||||
|
||||
const handleSaveOpenAi = async () => {
|
||||
if (!openaiKey.trim()) return;
|
||||
apiKeyService.setOpenAiKey(openaiKey.trim());
|
||||
setStatus(prev => ({ ...prev, openai: 'testing' }));
|
||||
const ok = await apiKeyService.testOpenAiConnection();
|
||||
setStatus(prev => ({ ...prev, openai: ok ? 'connected' : 'failed' }));
|
||||
const handleSave = async () => {
|
||||
if (!openaiKey.trim() || openaiKey.includes('•')) return;
|
||||
try {
|
||||
apiKeyService.setOpenAiKey(openaiKey.trim());
|
||||
setStatus({ openai: 'testing' });
|
||||
const ok = await apiKeyService.testOpenAiConnection();
|
||||
setStatus({ openai: ok ? 'connected' : 'failed' });
|
||||
if (ok) {
|
||||
setOpenaiKey(apiKeyService.getMaskedOpenAiKey());
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus({ openai: 'failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAnthropic = async () => {
|
||||
if (!anthropicKey.trim()) return;
|
||||
apiKeyService.setAnthropicKey(anthropicKey.trim());
|
||||
setStatus(prev => ({ ...prev, anthropic: 'testing' }));
|
||||
const ok = await apiKeyService.testAnthropicConnection();
|
||||
setStatus(prev => ({ ...prev, anthropic: ok ? 'connected' : 'failed' }));
|
||||
};
|
||||
|
||||
const handleClearOpenAi = () => {
|
||||
const handleClear = () => {
|
||||
apiKeyService.clearOpenAiKey();
|
||||
setOpenaiKey('');
|
||||
setStatus(prev => ({ ...prev, openai: 'not-set' }));
|
||||
};
|
||||
|
||||
const handleClearAnthropic = () => {
|
||||
apiKeyService.clearAnthropicKey();
|
||||
setAnthropicKey('');
|
||||
setStatus(prev => ({ ...prev, anthropic: 'not-set' }));
|
||||
setStatus({ openai: 'not-set' });
|
||||
};
|
||||
|
||||
const getStatusLabel = (s: string) => {
|
||||
if (s === 'connected') return { text: 'Connected', color: '#22c55e' };
|
||||
if (s === 'failed') return { text: 'Failed', color: '#ef4444' };
|
||||
if (s === 'testing') return { text: 'Testing...', color: '#6b7280' };
|
||||
return { text: 'Not set', color: '#6b7280' };
|
||||
return { text: 'Not configured', color: '#6b7280' };
|
||||
};
|
||||
|
||||
const statusInfo = getStatusLabel(status.openai);
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<p style={descStyle}>Keys are stored locally and never shared.</p>
|
||||
{/* Features explanation */}
|
||||
<div style={featuresBoxStyle}>
|
||||
<div style={featuresHeaderStyle}>OpenAI API Key enables:</div>
|
||||
<ul style={featuresListStyle}>
|
||||
<li>Auto-generated descriptions for new nodes</li>
|
||||
<li>Smart dimension assignment</li>
|
||||
<li>Semantic search via embeddings</li>
|
||||
</ul>
|
||||
<div style={noteStyle}>
|
||||
Without a key, you can still create and organize nodes manually.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style={toggleStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showKeys}
|
||||
onChange={(e) => setShowKeys(e.target.checked)}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
Show keys
|
||||
</label>
|
||||
|
||||
{/* OpenAI */}
|
||||
{/* Key input */}
|
||||
<div style={cardStyle}>
|
||||
<div style={cardHeaderStyle}>
|
||||
<span style={cardTitleStyle}>OpenAI</span>
|
||||
<span style={{ fontSize: 12, color: getStatusLabel(status.openai).color }}>
|
||||
{getStatusLabel(status.openai).text}
|
||||
<span style={cardTitleStyle}>OpenAI API Key</span>
|
||||
<span style={{ fontSize: 12, color: statusInfo.color }}>
|
||||
{statusInfo.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type={showKeys ? 'text' : 'password'}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={openaiKey}
|
||||
onChange={(e) => setOpenaiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
<div style={buttonRowStyle}>
|
||||
<button
|
||||
onClick={handleSaveOpenAi}
|
||||
disabled={!openaiKey.trim()}
|
||||
style={{ ...btnPrimaryStyle, opacity: openaiKey.trim() ? 1 : 0.4 }}
|
||||
onClick={handleSave}
|
||||
disabled={!openaiKey.trim() || openaiKey.includes('•')}
|
||||
style={{
|
||||
...btnPrimaryStyle,
|
||||
opacity: openaiKey.trim() && !openaiKey.includes('•') ? 1 : 0.4,
|
||||
}}
|
||||
>
|
||||
Save
|
||||
Save & Test
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearOpenAi}
|
||||
disabled={!openaiKey}
|
||||
style={{ ...btnSecondaryStyle, opacity: openaiKey ? 1 : 0.4 }}
|
||||
onClick={handleClear}
|
||||
disabled={status.openai === 'not-set'}
|
||||
style={{
|
||||
...btnSecondaryStyle,
|
||||
opacity: status.openai !== 'not-set' ? 1 : 0.4,
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<label style={toggleStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showKey}
|
||||
onChange={(e) => setShowKey(e.target.checked)}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
Show
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anthropic */}
|
||||
<div style={cardStyle}>
|
||||
<div style={cardHeaderStyle}>
|
||||
<span style={cardTitleStyle}>Anthropic</span>
|
||||
<span style={{ fontSize: 12, color: getStatusLabel(status.anthropic).color }}>
|
||||
{getStatusLabel(status.anthropic).text}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type={showKeys ? 'text' : 'password'}
|
||||
value={anthropicKey}
|
||||
onChange={(e) => setAnthropicKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={buttonRowStyle}>
|
||||
<button
|
||||
onClick={handleSaveAnthropic}
|
||||
disabled={!anthropicKey.trim()}
|
||||
style={{ ...btnPrimaryStyle, opacity: anthropicKey.trim() ? 1 : 0.4 }}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearAnthropic}
|
||||
disabled={!anthropicKey}
|
||||
style={{ ...btnSecondaryStyle, opacity: anthropicKey ? 1 : 0.4 }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help */}
|
||||
{/* Get key link */}
|
||||
<div style={helpStyle}>
|
||||
<div style={{ marginBottom: 8 }}>Get keys from:</div>
|
||||
<div>
|
||||
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noreferrer" style={linkStyle}>
|
||||
OpenAI →
|
||||
</a>
|
||||
<span style={{ margin: '0 12px', color: '#4b5563' }}>·</span>
|
||||
<a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noreferrer" style={linkStyle}>
|
||||
Anthropic →
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={linkStyle}
|
||||
>
|
||||
Get your API key from OpenAI →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -154,19 +131,34 @@ const containerStyle: CSSProperties = {
|
||||
overflow: 'auto',
|
||||
};
|
||||
|
||||
const descStyle: CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: '#6b7280',
|
||||
marginBottom: 16,
|
||||
const featuresBoxStyle: CSSProperties = {
|
||||
background: 'rgba(34, 197, 94, 0.08)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.2)',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
};
|
||||
|
||||
const toggleStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
const featuresHeaderStyle: CSSProperties = {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: '#22c55e',
|
||||
marginBottom: 8,
|
||||
};
|
||||
|
||||
const featuresListStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
paddingLeft: 20,
|
||||
fontSize: 13,
|
||||
color: '#d1d5db',
|
||||
lineHeight: 1.6,
|
||||
};
|
||||
|
||||
const noteStyle: CSSProperties = {
|
||||
marginTop: 12,
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
cursor: 'pointer',
|
||||
marginBottom: 20,
|
||||
fontStyle: 'italic',
|
||||
};
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
@@ -206,6 +198,7 @@ const inputStyle: CSSProperties = {
|
||||
const buttonRowStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
const btnPrimaryStyle: CSSProperties = {
|
||||
@@ -230,12 +223,16 @@ const btnSecondaryStyle: CSSProperties = {
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const toggleStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
cursor: 'pointer',
|
||||
marginLeft: 'auto',
|
||||
};
|
||||
|
||||
const helpStyle: CSSProperties = {
|
||||
marginTop: 8,
|
||||
padding: 16,
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -15,11 +15,66 @@ export interface DescriptionInput {
|
||||
dimensions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have a valid OpenAI API key configured.
|
||||
* Checks both environment variable and validates format.
|
||||
*/
|
||||
export function hasValidOpenAiKey(): boolean {
|
||||
const key = process.env.OPENAI_API_KEY;
|
||||
if (!key || key === 'your-openai-api-key-here') return false;
|
||||
// Valid OpenAI keys start with sk- or sk-proj-
|
||||
return key.startsWith('sk-') && key.length > 20;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a simple fallback description without AI.
|
||||
* Used when no API key is available or for simple inputs.
|
||||
*/
|
||||
export function generateFallbackDescription(input: DescriptionInput): string {
|
||||
const { title, type, metadata, dimensions } = input;
|
||||
|
||||
// Build a contextual fallback
|
||||
const parts: string[] = [];
|
||||
|
||||
if (metadata?.author || metadata?.channel_name) {
|
||||
parts.push(`By ${metadata.author || metadata.channel_name}`);
|
||||
}
|
||||
|
||||
if (type) {
|
||||
parts.push(type.charAt(0).toUpperCase() + type.slice(1));
|
||||
}
|
||||
|
||||
if (dimensions?.length) {
|
||||
parts.push(`in ${dimensions.slice(0, 2).join(', ')}`);
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
return `${parts.join(' — ')}: ${title.slice(0, 200)}`;
|
||||
}
|
||||
|
||||
return `Knowledge item: ${title.slice(0, 250)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 280-character description for a knowledge node.
|
||||
* Contextually grounded - adapts to node type (person, concept, article, etc.)
|
||||
*
|
||||
* IMPORTANT: Returns fallback immediately if no valid API key is configured.
|
||||
* This prevents slow node creation (9-13s timeout) when OpenAI is unavailable.
|
||||
*/
|
||||
export async function generateDescription(input: DescriptionInput): Promise<string> {
|
||||
// Fast path: skip AI if no valid API key
|
||||
if (!hasValidOpenAiKey()) {
|
||||
console.log(`[DescriptionService] No valid OpenAI key, using fallback for: "${input.title}"`);
|
||||
return generateFallbackDescription(input);
|
||||
}
|
||||
|
||||
// Fast path: skip AI for very short inputs (likely just notes)
|
||||
if (!input.content && !input.link && input.title.length < 30) {
|
||||
console.log(`[DescriptionService] Short input, using fallback for: "${input.title}"`);
|
||||
return generateFallbackDescription(input);
|
||||
}
|
||||
|
||||
try {
|
||||
const prompt = buildDescriptionPrompt(input);
|
||||
|
||||
@@ -43,7 +98,7 @@ export async function generateDescription(input: DescriptionInput): Promise<stri
|
||||
} catch (error) {
|
||||
console.error('[DescriptionService] Error generating description:', error);
|
||||
// Return a fallback description
|
||||
return `This is a ${input.type || 'knowledge item'} titled "${input.title.slice(0, 200)}".`;
|
||||
return generateFallbackDescription(input);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getSQLiteClient } from './sqlite-client';
|
||||
import { openai as openaiProvider } from '@ai-sdk/openai';
|
||||
import { generateText } from 'ai';
|
||||
import { hasValidOpenAiKey } from './descriptionService';
|
||||
|
||||
export interface Dimension {
|
||||
name: string;
|
||||
@@ -48,6 +49,9 @@ export class DimensionService {
|
||||
/**
|
||||
* Automatically assign locked dimensions + suggest keyword dimensions
|
||||
* Returns { locked: string[], keywords: string[] }
|
||||
*
|
||||
* IMPORTANT: Returns empty result immediately if no valid API key is configured.
|
||||
* This prevents slow node creation when OpenAI is unavailable.
|
||||
*/
|
||||
static async assignDimensions(nodeData: {
|
||||
title: string;
|
||||
@@ -55,6 +59,12 @@ export class DimensionService {
|
||||
link?: string;
|
||||
description?: string;
|
||||
}): Promise<{ locked: string[]; keywords: string[] }> {
|
||||
// Fast path: skip AI if no valid API key
|
||||
if (!hasValidOpenAiKey()) {
|
||||
console.log(`[DimensionAssignment] No valid OpenAI key, skipping for: "${nodeData.title}"`);
|
||||
return { locked: [], keywords: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const lockedDimensions = await this.getLockedDimensions();
|
||||
|
||||
|
||||
+39
-108
@@ -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
|
||||
@@ -154,9 +106,9 @@ export class ApiKeyService {
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/models', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${testKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
Authorization: `Bearer ${testKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const isConnected = response.ok;
|
||||
@@ -169,47 +121,26 @@ export class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection to Anthropic
|
||||
async testAnthropicConnection(key?: string): Promise<boolean> {
|
||||
const testKey = key || this.getAnthropicKey();
|
||||
if (!testKey) return false;
|
||||
|
||||
this.status.anthropic = 'testing';
|
||||
|
||||
try {
|
||||
// Simple test call to Anthropic API
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': testKey,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-3-haiku-20240307',
|
||||
max_tokens: 1,
|
||||
messages: [{ role: 'user', content: 'test' }]
|
||||
})
|
||||
});
|
||||
|
||||
const isConnected = response.ok;
|
||||
this.status.anthropic = isConnected ? 'connected' : 'failed';
|
||||
return isConnected;
|
||||
} catch (error) {
|
||||
console.error('Anthropic connection test failed:', error);
|
||||
this.status.anthropic = 'failed';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get connection status
|
||||
getStatus(): ApiKeyStatus {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
// Update status
|
||||
updateStatus(provider: 'openai' | 'anthropic', status: ApiKeyStatus['openai']): void {
|
||||
this.status[provider] = status;
|
||||
updateStatus(status: ApiKeyStatus['openai']): void {
|
||||
this.status.openai = status;
|
||||
}
|
||||
|
||||
// First-run tracking
|
||||
isFirstRun(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return !localStorage.getItem(FIRST_RUN_KEY);
|
||||
}
|
||||
|
||||
markFirstRunComplete(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(FIRST_RUN_KEY, 'true');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user