Initial commit: RA-H Open Source Edition

Local-first knowledge management system with BYO API keys.

Features:
- 3-panel UI (Nodes | Focus | Helpers)
- SQLite + sqlite-vec for vector search
- Agent system (Easy/Hard mode orchestrators)
- Content extraction (YouTube, PDF, web)
- Integrate workflow for connection discovery
- Dimension system with auto-assignment

Tech stack:
- Next.js 15 + TypeScript + Tailwind CSS
- Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK

Setup:
  npm install && npm rebuild better-sqlite3
  scripts/dev/bootstrap-local.sh
  npm run dev

MIT License
This commit is contained in:
“BeeRad”
2025-12-15 16:14:28 +11:00
commit 733d1c3407
226 changed files with 46231 additions and 0 deletions
+350
View File
@@ -0,0 +1,350 @@
"use client";
import { useState, useEffect } from 'react';
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);
useEffect(() => {
// Load existing keys and status
const stored = apiKeyService.getStoredKeys();
setOpenaiKey(stored.openai || '');
setAnthropicKey(stored.anthropic || '');
setStatus(apiKeyService.getStatus());
}, []);
const handleSaveOpenAi = async () => {
if (!openaiKey.trim()) return;
try {
apiKeyService.setOpenAiKey(openaiKey.trim());
// Test the connection
const isConnected = await apiKeyService.testOpenAiConnection();
setStatus(prev => ({ ...prev, openai: isConnected ? 'connected' : 'failed' }));
if (isConnected) {
alert('OpenAI API key saved and connection verified!');
} else {
alert('OpenAI API key saved but connection failed. Please check your key.');
}
} catch (error) {
alert(`Failed to save OpenAI key: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleSaveAnthropic = async () => {
if (!anthropicKey.trim()) return;
try {
apiKeyService.setAnthropicKey(anthropicKey.trim());
// Test the connection
const isConnected = await apiKeyService.testAnthropicConnection();
setStatus(prev => ({ ...prev, anthropic: isConnected ? 'connected' : 'failed' }));
if (isConnected) {
alert('Anthropic API key saved and connection verified!');
} else {
alert('Anthropic API key saved but connection failed. Please check your key.');
}
} catch (error) {
alert(`Failed to save Anthropic key: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleTestOpenAi = async () => {
setStatus(prev => ({ ...prev, openai: 'testing' }));
const isConnected = await apiKeyService.testOpenAiConnection();
setStatus(prev => ({ ...prev, openai: isConnected ? 'connected' : 'failed' }));
};
const handleTestAnthropic = async () => {
setStatus(prev => ({ ...prev, anthropic: 'testing' }));
const isConnected = await apiKeyService.testAnthropicConnection();
setStatus(prev => ({ ...prev, anthropic: isConnected ? 'connected' : 'failed' }));
};
const handleClearOpenAi = () => {
apiKeyService.clearOpenAiKey();
setOpenaiKey('');
setStatus(prev => ({ ...prev, openai: 'not-set' }));
};
const handleClearAnthropic = () => {
apiKeyService.clearAnthropicKey();
setAnthropicKey('');
setStatus(prev => ({ ...prev, anthropic: 'not-set' }));
};
const getStatusIcon = (statusValue: string) => {
switch (statusValue) {
case 'connected': return '✅';
case 'failed': return '❌';
case 'testing': return '⏳';
default: return '⚪';
}
};
const getStatusColor = (statusValue: string) => {
switch (statusValue) {
case 'connected': return '#22c55e';
case 'failed': return '#ef4444';
case 'testing': return '#f59e0b';
default: return '#6b7280';
}
};
return (
<div style={{
padding: '24px',
height: '100%',
overflow: 'auto',
background: '#0f0f0f',
color: '#fff'
}}>
<div style={{ marginBottom: '32px' }}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: '600' }}>
API Configuration
</h3>
<p style={{ margin: 0, fontSize: '14px', color: '#888', lineHeight: '1.5' }}>
Keys are stored locally and never shared with RA-H servers.
</p>
</div>
{/* Show/Hide Keys Toggle */}
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'flex',
alignItems: 'center',
fontSize: '14px',
cursor: 'pointer',
color: '#888'
}}>
<input
type="checkbox"
checked={showKeys}
onChange={(e) => setShowKeys(e.target.checked)}
style={{ marginRight: '8px' }}
/>
Show API keys in plain text
</label>
</div>
{/* OpenAI Section */}
<div style={{
marginBottom: '32px',
padding: '20px',
border: '1px solid #2a2a2a',
borderRadius: '8px',
background: '#0a0a0a'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '16px'
}}>
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: '600' }}>
OpenAI API Key
</h4>
<div style={{
marginLeft: '12px',
display: 'flex',
alignItems: 'center',
fontSize: '12px',
color: getStatusColor(status.openai)
}}>
<span style={{ marginRight: '4px' }}>
{getStatusIcon(status.openai)}
</span>
<span>
{status.openai === 'connected' && 'Connected'}
{status.openai === 'failed' && 'Connection Failed'}
{status.openai === 'testing' && 'Testing...'}
{status.openai === 'not-set' && 'Not Set'}
</span>
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<input
type={showKeys ? 'text' : 'password'}
value={openaiKey}
onChange={(e) => setOpenaiKey(e.target.value)}
placeholder="sk-proj-... or sk-..."
style={{
width: '100%',
padding: '12px',
fontSize: '14px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
color: '#fff',
fontFamily: 'monospace'
}}
/>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
onClick={handleSaveOpenAi}
disabled={!openaiKey.trim()}
style={{
padding: '8px 16px',
fontSize: '12px',
background: '#22c55e',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: openaiKey.trim() ? 'pointer' : 'not-allowed',
opacity: openaiKey.trim() ? 1 : 0.5
}}
>
Save & Test
</button>
<button
onClick={handleClearOpenAi}
disabled={!openaiKey}
style={{
padding: '8px 16px',
fontSize: '12px',
background: '#ef4444',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: openaiKey ? 'pointer' : 'not-allowed',
opacity: openaiKey ? 1 : 0.5
}}
>
Clear
</button>
</div>
</div>
{/* Anthropic Section */}
<div style={{
marginBottom: '32px',
padding: '20px',
border: '1px solid #2a2a2a',
borderRadius: '8px',
background: '#0a0a0a'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '16px'
}}>
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: '600' }}>
Anthropic API Key
</h4>
<div style={{
marginLeft: '12px',
display: 'flex',
alignItems: 'center',
fontSize: '12px',
color: getStatusColor(status.anthropic)
}}>
<span style={{ marginRight: '4px' }}>
{getStatusIcon(status.anthropic)}
</span>
<span>
{status.anthropic === 'connected' && 'Connected'}
{status.anthropic === 'failed' && 'Connection Failed'}
{status.anthropic === 'testing' && 'Testing...'}
{status.anthropic === 'not-set' && 'Not Set'}
</span>
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<input
type={showKeys ? 'text' : 'password'}
value={anthropicKey}
onChange={(e) => setAnthropicKey(e.target.value)}
placeholder="sk-ant-api03-..."
style={{
width: '100%',
padding: '12px',
fontSize: '14px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
color: '#fff',
fontFamily: 'monospace'
}}
/>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
onClick={handleSaveAnthropic}
disabled={!anthropicKey.trim()}
style={{
padding: '8px 16px',
fontSize: '12px',
background: '#22c55e',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: anthropicKey.trim() ? 'pointer' : 'not-allowed',
opacity: anthropicKey.trim() ? 1 : 0.5
}}
>
Save & Test
</button>
<button
onClick={handleClearAnthropic}
disabled={!anthropicKey}
style={{
padding: '8px 16px',
fontSize: '12px',
background: '#ef4444',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: anthropicKey ? 'pointer' : 'not-allowed',
opacity: anthropicKey ? 1 : 0.5
}}
>
Clear
</button>
</div>
</div>
{/* Help Section */}
<div style={{
padding: '16px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
fontSize: '12px',
color: '#888',
lineHeight: '1.5'
}}>
<h5 style={{ margin: '0 0 8px 0', color: '#fff', fontSize: '13px' }}>
How to get API keys:
</h5>
<ul style={{ margin: 0, paddingLeft: '16px' }}>
<li style={{ marginBottom: '4px' }}>
<strong>OpenAI:</strong> Visit <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" style={{ color: '#22c55e' }}>platform.openai.com/api-keys</a>
</li>
<li>
<strong>Anthropic:</strong> Visit <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener noreferrer" style={{ color: '#22c55e' }}>console.anthropic.com/settings/keys</a>
</li>
</ul>
<p style={{ margin: '12px 0 0 0', fontSize: '11px' }}>
Your keys are stored locally and never sent to RA-H servers. They are only used to communicate directly with OpenAI and Anthropic APIs.
</p>
</div>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
"use client";
import { useEffect, useMemo, useState } from 'react';
import type { Node } from '@/types/database';
interface AutoContextSettings {
autoContextEnabled: boolean;
lastPinnedMigration?: string;
}
interface NodeWithMetrics extends Node {
edge_count?: number;
}
export default function ContextViewer() {
const [nodes, setNodes] = useState<NodeWithMetrics[]>([]);
const [autoContextEnabled, setAutoContextEnabled] = useState(false);
const [lastMigrated, setLastMigrated] = useState<string | undefined>();
const [loadingNodes, setLoadingNodes] = useState(true);
const [loadingSettings, setLoadingSettings] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadNodes = async () => {
setLoadingNodes(true);
try {
const response = await fetch('/api/nodes?sortBy=edges&limit=10');
if (!response.ok) {
throw new Error('Failed to load nodes');
}
const payload = await response.json();
setNodes(payload.data || []);
} catch (err) {
console.error(err);
setError('Unable to load top nodes.');
} finally {
setLoadingNodes(false);
}
};
const loadSettings = async () => {
setLoadingSettings(true);
try {
const response = await fetch('/api/system/auto-context');
if (!response.ok) {
throw new Error('Failed to load auto-context settings');
}
const payload = (await response.json()) as {
success: boolean;
data?: AutoContextSettings;
};
if (payload.success && payload.data) {
setAutoContextEnabled(Boolean(payload.data.autoContextEnabled));
setLastMigrated(payload.data.lastPinnedMigration);
}
} catch (err) {
console.error(err);
setError('Unable to load auto-context settings.');
} finally {
setLoadingSettings(false);
}
};
loadNodes();
loadSettings();
}, []);
const toggleDescription = useMemo(() => {
if (!autoContextEnabled) {
return 'Disabled — chats and workflows will not receive BACKGROUND CONTEXT.';
}
return 'Enabled — top 10 most-connected nodes will be added to BACKGROUND CONTEXT.';
}, [autoContextEnabled]);
const handleToggle = async () => {
if (saving) return;
setSaving(true);
setError(null);
try {
const response = await fetch('/api/system/auto-context', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoContextEnabled: !autoContextEnabled }),
});
if (!response.ok) {
throw new Error(await response.text());
}
const payload = (await response.json()) as {
success: boolean;
data?: AutoContextSettings;
};
if (payload.success && payload.data) {
setAutoContextEnabled(payload.data.autoContextEnabled);
setLastMigrated(payload.data.lastPinnedMigration);
} else {
throw new Error(payload?.data ? 'Settings update failed' : 'Unknown error');
}
} catch (err) {
console.error('Failed to update auto-context toggle', err);
setError('Unable to update setting. Please try again.');
} finally {
setSaving(false);
}
};
return (
<div style={{ height: '100%', overflow: 'auto', padding: '24px', color: '#f8fafc' }}>
<div style={{ marginBottom: '24px' }}>
<h3 style={{ margin: '0 0 8px', fontSize: '18px' }}>Auto-Context</h3>
<p style={{ margin: 0, color: '#94a3b8', fontSize: '13px', lineHeight: 1.6 }}>
Auto-context grabs your 10 nodes with the most edges and drops them into BACKGROUND
CONTEXT so ra-h knows which ideas connect everything else.
</p>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
border: '1px solid #1f2937',
borderRadius: '10px',
background: '#050505',
marginBottom: '24px',
}}
>
<div>
<div style={{ fontWeight: 600, fontSize: '14px' }}>Enable Auto-Context</div>
<div style={{ fontSize: '12px', color: '#94a3b8', marginTop: '4px' }}>
{loadingSettings ? 'Loading preference…' : toggleDescription}
</div>
</div>
<button
onClick={handleToggle}
disabled={loadingSettings || saving}
style={{
width: '58px',
height: '30px',
borderRadius: '999px',
border: 'none',
cursor: saving ? 'wait' : 'pointer',
background: autoContextEnabled ? '#22c55e' : '#334155',
position: 'relative',
transition: 'background 0.2s ease',
}}
title={autoContextEnabled ? 'Disable auto-context' : 'Enable auto-context'}
>
<span
style={{
position: 'absolute',
top: '4px',
left: autoContextEnabled ? '32px' : '4px',
width: '22px',
height: '22px',
borderRadius: '50%',
background: '#fff',
transition: 'left 0.2s ease',
}}
/>
</button>
</div>
{lastMigrated && (
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '16px' }}>
Auto-enabled because you previously pinned nodes · {new Date(lastMigrated).toLocaleString()}
</div>
)}
<div>
<div style={{ fontWeight: 600, marginBottom: '12px' }}>Top 10 nodes by connections</div>
{loadingNodes ? (
<div style={{ color: '#94a3b8', fontSize: '13px' }}>Loading nodes</div>
) : nodes.length === 0 ? (
<div style={{ color: '#94a3b8', fontSize: '13px' }}>
No connected nodes yet. Create nodes and add edges to see context hubs.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{nodes.map((node) => (
<div
key={node.id}
style={{
border: '1px solid #1f2937',
borderRadius: '10px',
padding: '12px 16px',
background: '#080808',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '4px',
}}
>
<div style={{ fontWeight: 600 }}>
[NODE:{node.id}] {node.title || 'Untitled node'}
</div>
<div style={{ fontSize: '12px', color: '#facc15' }}>
{node.edge_count ?? 0} connections
</div>
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{node.dimensions && node.dimensions.length > 0 ? (
node.dimensions.slice(0, 4).map((dimension) => (
<span
key={`${node.id}-${dimension}`}
style={{
padding: '2px 8px',
borderRadius: '999px',
fontSize: '11px',
background: '#132018',
color: '#86efac',
}}
>
{dimension}
</span>
))
) : (
<span style={{ fontSize: '11px', color: '#64748b' }}>No dimensions</span>
)}
</div>
</div>
))}
</div>
)}
</div>
{error && (
<div style={{ marginTop: '24px', color: '#f87171', fontSize: '12px' }}>
{error}
</div>
)}
</div>
);
}
+526
View File
@@ -0,0 +1,526 @@
"use client";
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react';
import { Folder, Link as LinkIcon } from 'lucide-react';
import { Node } from '@/types/database';
interface NodeWithMetrics extends Node {
edge_count?: number;
}
interface AppliedFilters {
search?: string;
dimensions?: string[];
sortBy: 'updated' | 'edges';
}
interface PopularDimension {
dimension: string;
count: number;
isPriority: boolean;
}
const LIMIT = 50;
export default function DatabaseViewer() {
const [nodes, setNodes] = useState<NodeWithMetrics[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchInput, setSearchInput] = useState('');
const [dimensionInput, setDimensionInput] = useState('');
const [dimensionFilters, setDimensionFilters] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<'updated' | 'edges'>('updated');
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({ sortBy: 'updated' });
const [lockedDimensionSet, setLockedDimensionSet] = useState<Set<string>>(new Set());
const [contextHubIds, setContextHubIds] = useState<Set<number>>(new Set());
const filtersActive = useMemo(
() => Boolean(appliedFilters.search || (appliedFilters.dimensions && appliedFilters.dimensions.length > 0)),
[appliedFilters]
);
const fetchNodes = useCallback(
async (pageNumber: number, filters: AppliedFilters) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
params.set('limit', LIMIT.toString());
params.set('offset', ((pageNumber - 1) * LIMIT).toString());
params.set('sortBy', filters.sortBy);
if (filters.search) params.set('search', filters.search);
if (filters.dimensions && filters.dimensions.length > 0) {
params.set('dimensions', filters.dimensions.join(','));
}
const response = await fetch(`/api/nodes?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch nodes');
}
const data = await response.json();
setNodes(data.data || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setNodes([]);
} finally {
setLoading(false);
}
},
[]
);
useEffect(() => {
fetchNodes(page, appliedFilters);
}, [page, appliedFilters, fetchNodes]);
useEffect(() => {
const loadLockedDimensions = async () => {
try {
const response = await fetch('/api/dimensions/popular');
if (!response.ok) return;
const result = await response.json();
if (result.success) {
const priorityDimensions: PopularDimension[] = result.data;
setLockedDimensionSet(new Set(priorityDimensions.filter((d) => d.isPriority).map((d) => d.dimension)));
}
} catch (err) {
console.warn('Failed to load locked dimensions', err);
}
};
loadLockedDimensions();
}, []);
useEffect(() => {
const loadContextHubs = async () => {
try {
const response = await fetch('/api/nodes?sortBy=edges&limit=10');
if (!response.ok) return;
const payload = await response.json();
const ids = new Set<number>((payload.data || []).map((node: Node) => node.id));
setContextHubIds(ids);
} catch (err) {
console.warn('Failed to load auto-context hubs', err);
}
};
loadContextHubs();
}, []);
const handleApplyFilters = () => {
const payload: AppliedFilters = {
sortBy,
};
if (searchInput.trim()) payload.search = searchInput.trim();
if (dimensionFilters.length > 0) payload.dimensions = dimensionFilters;
setAppliedFilters(payload);
setPage(1);
};
const handleClearFilters = () => {
setSearchInput('');
setDimensionInput('');
setDimensionFilters([]);
setSortBy('updated');
setAppliedFilters({ sortBy: 'updated' });
setPage(1);
};
const handleAddDimension = () => {
const next = dimensionInput.trim();
if (!next) return;
setDimensionFilters((prev) => (prev.includes(next) ? prev : [...prev, next]));
setDimensionInput('');
};
const handleDimensionKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddDimension();
}
};
const handleRemoveDimension = (dimension: string) => {
setDimensionFilters((prev) => prev.filter((dim) => dim !== dimension));
};
const handleSortChange = (event: ChangeEvent<HTMLSelectElement>) => {
const value = event.target.value === 'edges' ? 'edges' : 'updated';
setSortBy(value);
setAppliedFilters((prev) => ({ ...prev, sortBy: value }));
};
const handlePrevious = () => {
setPage((prev) => (prev > 1 ? prev - 1 : prev));
};
const handleNext = () => {
if (nodes.length === LIMIT) {
setPage((prev) => prev + 1);
}
};
const isFirstPage = page === 1;
const isLastPage = nodes.length < LIMIT;
const filterStatus = filtersActive
? 'Filtered results'
: `Showing ${(page - 1) * LIMIT + 1}-${(page - 1) * LIMIT + nodes.length}`;
const formatTimestamp = (value?: string) => {
if (!value) return '—';
const date = new Date(value);
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatRelative = (value?: string) => {
if (!value) return '';
const diffMs = Date.now() - new Date(value).getTime();
const diffMinutes = Math.round(diffMs / (1000 * 60));
if (diffMinutes < 60) return `${diffMinutes}m ago`;
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.round(diffHours / 24);
return `${diffDays}d ago`;
};
if (loading) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
Loading database...
</div>
);
}
if (error) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
Error: {error}
</div>
);
}
if (nodes.length === 0) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
No nodes found
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
padding: '16px 24px',
borderBottom: '1px solid #2a2a2a',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
Search
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="title or content"
style={{
background: '#0f0f0f',
border: '1px solid #2a2a2a',
color: '#ddd',
padding: '6px 10px',
borderRadius: '4px',
minWidth: '220px',
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
Dimension
<div style={{ display: 'flex', gap: '8px' }}>
<input
value={dimensionInput}
onChange={(e) => setDimensionInput(e.target.value)}
onKeyDown={handleDimensionKeyDown}
placeholder="e.g. research"
style={{
background: '#0f0f0f',
border: '1px solid #2a2a2a',
color: '#ddd',
padding: '6px 10px',
borderRadius: '4px',
minWidth: '180px',
}}
/>
<button
onClick={handleAddDimension}
style={{
padding: '6px 10px',
background: '#1f3529',
border: '1px solid #264333',
borderRadius: '4px',
color: '#c4f5d2',
fontSize: '12px',
cursor: 'pointer',
}}
>
Add
</button>
</div>
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
Sort by
<select
value={sortBy}
onChange={handleSortChange}
style={{
background: '#0f0f0f',
border: '1px solid #2a2a2a',
color: '#ddd',
padding: '6px 10px',
borderRadius: '4px',
}}
>
<option value="updated">Recently updated</option>
<option value="edges">Most connected</option>
</select>
</label>
</div>
{dimensionFilters.length > 0 && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{dimensionFilters.map((dimension) => (
<span
key={dimension}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '3px 10px',
borderRadius: '999px',
background: '#142817',
color: '#c4f5d2',
fontSize: '11px',
border: '1px solid #1f3b23',
}}
>
<Folder size={12} />
{dimension}
<button
onClick={() => handleRemoveDimension(dimension)}
style={{
marginLeft: '2px',
background: 'transparent',
border: 'none',
color: '#7de8a5',
cursor: 'pointer',
fontSize: '11px',
}}
aria-label={`Remove ${dimension}`}
>
×
</button>
</span>
))}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: '12px', color: '#666' }}>{filterStatus}</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleApplyFilters}
style={{
padding: '8px 16px',
background: '#22c55e33',
border: '1px solid #22c55e66',
borderRadius: '4px',
color: '#22c55e',
cursor: 'pointer',
fontSize: '12px',
}}
>
Apply
</button>
<button
onClick={handleClearFilters}
style={{
padding: '8px 16px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
color: '#ccc',
cursor: 'pointer',
fontSize: '12px',
}}
>
Clear
</button>
<button
onClick={handlePrevious}
disabled={isFirstPage || filtersActive}
style={{
padding: '8px 12px',
background: isFirstPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
border: '1px solid #333',
borderRadius: '4px',
color: isFirstPage || filtersActive ? '#555' : '#ccc',
cursor: isFirstPage || filtersActive ? 'not-allowed' : 'pointer',
fontSize: '12px',
}}
>
Previous
</button>
<button
onClick={handleNext}
disabled={isLastPage || filtersActive}
style={{
padding: '8px 12px',
background: isLastPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
border: '1px solid #333',
borderRadius: '4px',
color: isLastPage || filtersActive ? '#555' : '#ccc',
cursor: isLastPage || filtersActive ? 'not-allowed' : 'pointer',
fontSize: '12px',
}}
>
Next
</button>
</div>
</div>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
<tr>
{['Node', 'Categories', 'Edges', 'Highlights', 'Updated', 'Created'].map((column) => (
<th
key={column}
style={{
padding: '12px 16px',
textAlign: 'left',
fontSize: '11px',
color: '#888',
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontWeight: 'normal',
borderBottom: '1px solid #2a2a2a',
}}
>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{nodes.map((node, index) => {
const belongsToLocked = node.dimensions?.some((dimension) => lockedDimensionSet.has(dimension));
return (
<tr
key={node.id}
style={{
background: index % 2 === 0 ? '#080808' : '#0d0d0d',
borderBottom: '1px solid #111',
}}
>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '28%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ fontWeight: 600, color: '#f5f5f5', display: 'flex', alignItems: 'center', gap: '8px' }}>
{node.title || 'Untitled node'}
{node.link && (
<button
onClick={() => window.open(node.link, '_blank')}
title="Open original link"
style={{
background: 'transparent',
border: 'none',
color: '#7de8a5',
cursor: 'pointer',
padding: 0,
display: 'flex',
alignItems: 'center',
}}
>
<LinkIcon size={14} />
</button>
)}
</div>
<span style={{ fontSize: '11px', color: '#666', fontFamily: 'JetBrains Mono, monospace' }}>ID: {node.id}</span>
</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '24%' }}>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{node.dimensions && node.dimensions.length > 0 ? (
node.dimensions.slice(0, 3).map((dimension) => (
<span
key={`${node.id}-${dimension}`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '999px',
background: '#111914',
border: '1px solid #1f2f24',
fontSize: '11px',
color: '#bbf7d0',
}}
>
<Folder size={11} />
{dimension}
</span>
))
) : (
<span style={{ fontSize: '11px', color: '#555' }}>No categories</span>
)}
{node.dimensions && node.dimensions.length > 3 && (
<span style={{ fontSize: '11px', color: '#666' }}>+{node.dimensions.length - 3} more</span>
)}
</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
<div style={{ fontWeight: 600, color: '#e2e8f0' }}>{node.edge_count ?? 0}</div>
<div style={{ fontSize: '11px', color: '#666' }}>connections</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '14%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{contextHubIds.has(node.id) ? (
<span style={{ fontSize: '11px', color: '#facc15', fontWeight: 600 }}>
Auto-context hub
</span>
) : (
<span style={{ fontSize: '11px', color: '#475569' }}></span>
)}
{belongsToLocked && (
<span style={{ fontSize: '11px', color: '#7de8a5' }}>Priority dimension</span>
)}
</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
<div style={{ fontSize: '12px', color: '#e2e8f0' }}>{formatTimestamp(node.updated_at)}</div>
<div style={{ fontSize: '11px', color: '#666' }}>{formatRelative(node.updated_at)}</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
<div style={{ fontSize: '12px', color: '#cbd5f5' }}>{formatTimestamp(node.created_at)}</div>
<div style={{ fontSize: '11px', color: '#666' }}>{formatRelative(node.created_at)}</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,188 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from 'react';
interface McpStatus {
enabled: boolean;
url: string | null;
port: number | null;
last_updated?: string | null;
target_base_url?: string | null;
last_error?: string | null;
error?: string | null;
}
const initialStatus: McpStatus = {
enabled: false,
url: null,
port: null
};
export default function ExternalAgentsPanel() {
const [status, setStatus] = useState<McpStatus>(initialStatus);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copyState, setCopyState] = useState<'idle' | 'copied'>('idle');
useEffect(() => {
const fetchStatus = async () => {
try {
setLoading(true);
const response = await fetch('/api/system/mcp-status');
const data = await response.json();
setStatus(data);
setError(null);
} catch (err) {
console.error('Failed to load MCP status', err);
setError('Unable to read local MCP status. Open the desktop app to bootstrap it.');
setStatus(initialStatus);
} finally {
setLoading(false);
}
};
fetchStatus();
const timer = setInterval(fetchStatus, 5000);
return () => clearInterval(timer);
}, []);
const connectorUrl = useMemo(() => {
if (status?.url) return status.url;
if (status?.port) return `http://127.0.0.1:${status.port}/mcp`;
return null;
}, [status]);
const handleCopy = useCallback(async () => {
if (!connectorUrl) return;
try {
await navigator.clipboard.writeText(connectorUrl);
setCopyState('copied');
setTimeout(() => setCopyState('idle'), 2000);
} catch (err) {
console.error('Copy failed', err);
}
}, [connectorUrl]);
return (
<div style={{ padding: '32px', color: '#e2e8f0', overflowY: 'auto' }}>
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '8px' }}>External Agents</h2>
<p style={{ color: '#94a3b8', marginBottom: '24px', lineHeight: 1.5 }}>
Connect Claude, ChatGPT, Gemini, or any MCP-compatible assistant to your local RA-H database.
Everything stays on devicetools simply call this connector to add or search nodes.
</p>
<div
style={{
border: '1px solid #1f2937',
borderRadius: '10px',
padding: '20px',
marginBottom: '24px',
background: '#090909'
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: '14px', color: '#94a3b8' }}>Connector URL</div>
<div style={{ fontSize: '18px', color: connectorUrl ? '#fff' : '#64748b', marginTop: '4px' }}>
{loading ? 'Loading…' : connectorUrl ?? 'Unavailable (start the RA-H desktop app)'}
</div>
{status.last_updated && (
<div style={{ fontSize: '12px', color: '#475569', marginTop: '6px' }}>
Updated {new Date(status.last_updated).toLocaleTimeString()}
</div>
)}
</div>
<button
type="button"
onClick={handleCopy}
disabled={!connectorUrl}
style={{
background: connectorUrl ? '#22c55e' : '#1f2937',
color: connectorUrl ? '#000' : '#475569',
border: 'none',
borderRadius: '6px',
padding: '10px 16px',
cursor: connectorUrl ? 'pointer' : 'not-allowed',
fontWeight: 600
}}
>
{copyState === 'copied' ? 'Copied ✓' : 'Copy URL'}
</button>
</div>
{status.last_error && (
<div style={{ marginTop: '12px', fontSize: '13px', color: '#fbbf24' }}>
{status.last_error}
</div>
)}
</div>
<div
style={{
border: '1px solid #1f2937',
borderRadius: '10px',
padding: '20px',
marginBottom: '24px',
background: '#0c111d'
}}
>
<h3 style={{ marginBottom: '12px', fontSize: '16px', fontWeight: 600 }}>How to use in Claude or ChatGPT</h3>
<ol style={{ paddingLeft: '20px', lineHeight: 1.6, color: '#cbd5f5' }}>
<li>Open the MCP / connectors settings in your assistant.</li>
<li>Select Add connector choose HTTP paste the URL above.</li>
<li>Give the connector a friendly name (e.g., RA-H).</li>
<li>Ask naturally: Add this summary to RA-H or Search RA-H for my Apollo notes.</li>
</ol>
</div>
<div
style={{
border: '1px solid #3f1d1d',
borderRadius: '10px',
padding: '16px',
background: '#170e0e',
color: '#fca5a5',
marginBottom: '24px',
lineHeight: 1.5
}}
>
External agents can edit your local graph. Only enable trusted connectors and monitor their output.
Disconnect the connector or close RA-H if anything unexpected happens.
</div>
{error && (
<div style={{ color: '#f87171', marginBottom: '16px', fontSize: '14px' }}>{error}</div>
)}
<div style={{ display: 'grid', gap: '16px' }}>
<HelperCard
title="Add to RA-H"
body={`"Summarize our meeting and add it to RA-H under dimensions Strategy, Q1 Execution."`}
/>
<HelperCard
title="Search RA-H"
body={`"Search RA-H for what I previously wrote about the Apollo launch delays."`}
/>
<HelperCard
title="Check nodes before writing"
body={`"Before adding anything new, call rah.search_nodes to see if the note already exists."`}
/>
</div>
</div>
);
}
function HelperCard({ title, body }: { title: string; body: string }) {
return (
<div
style={{
border: '1px solid #1f2937',
borderRadius: '8px',
padding: '14px',
background: '#0f172a'
}}
>
<div style={{ fontWeight: 600, marginBottom: '6px', color: '#f1f5f9' }}>{title}</div>
<div style={{ color: '#cbd5f5', fontSize: '14px', lineHeight: 1.5 }}>{body}</div>
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useState } from 'react';
import { LogEntry } from '@/types/logs';
interface LogsRowProps {
log: LogEntry;
isEven: boolean;
}
export default function LogsRow({ log, isEven }: LogsRowProps) {
const [expanded, setExpanded] = useState(false);
const formatTimestamp = (ts: string) => {
const date = new Date(ts);
return date.toISOString().replace('T', ' ').substring(0, 19);
};
const formatJson = (jsonStr: string | null) => {
if (!jsonStr) return 'null';
try {
const parsed = JSON.parse(jsonStr);
return JSON.stringify(parsed, null, 2);
} catch {
return jsonStr;
}
};
const highlightJson = (jsonStr: string) => {
return jsonStr
.replace(/"([^"]+)":/g, '<span style="color: #60a5fa">"$1"</span>:')
.replace(/: "([^"]*)"/g, ': <span style="color: #34d399">"$1"</span>')
.replace(/: (\d+)/g, ': <span style="color: #fb923c">$1</span>')
.replace(/: (true|false|null)/g, ': <span style="color: #a78bfa">$1</span>');
};
const getMetricsFromSnapshot = () => {
if (!log.snapshot_json || log.table_name !== 'chats') return null;
try {
const snapshot = JSON.parse(log.snapshot_json);
return {
thread: snapshot.thread,
trace_id: snapshot.trace_id,
input_tokens: snapshot.input_tokens,
output_tokens: snapshot.output_tokens,
cost_usd: snapshot.cost_usd,
cache_hit: snapshot.cache_hit,
model: snapshot.model,
system_message: snapshot.system_message
};
} catch {
return null;
}
};
const metrics = getMetricsFromSnapshot();
return (
<>
<tr
onClick={() => setExpanded(!expanded)}
style={{
background: isEven ? '#0f0f0f' : '#141414',
cursor: 'pointer',
borderBottom: '1px solid #2a2a2a'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#1a1a1a';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = isEven ? '#0f0f0f' : '#141414';
}}
>
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '60px' }}>
{log.id}
</td>
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '180px' }}>
{formatTimestamp(log.ts)}
</td>
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '100px' }}>
{log.table_name}
</td>
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '80px' }}>
{log.action}
</td>
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace' }}>
<div>{log.summary || '-'}</div>
{metrics && (
<div style={{ marginTop: '6px', fontSize: '10px', color: '#888', display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
{metrics.trace_id && (
<span title={`Trace: ${metrics.trace_id}`}>
🔗 {metrics.trace_id.substring(0, 8)}
</span>
)}
{metrics.thread && (
<span title={`Thread: ${metrics.thread}`}>
🧵 {metrics.thread.substring(0, 16)}
</span>
)}
{metrics.cost_usd !== undefined && (
<span style={{ color: '#34d399' }}>
💰 ${metrics.cost_usd.toFixed(6)}
</span>
)}
{metrics.input_tokens !== undefined && metrics.output_tokens !== undefined && (
<span>
📊 {metrics.input_tokens} {metrics.output_tokens}
</span>
)}
{metrics.cache_hit !== undefined && metrics.cache_hit === 1 && (
<span style={{ color: '#60a5fa' }}>
Cache Hit
</span>
)}
</div>
)}
</td>
<td style={{ padding: '12px 16px', fontSize: '12px', fontFamily: 'JetBrains Mono, monospace', width: '80px' }}>
{log.row_id}
</td>
</tr>
{expanded && (
<tr style={{ background: '#0a0a0a', borderTop: '1px solid #333', borderBottom: '1px solid #333' }}>
<td colSpan={6} style={{ padding: '16px 24px' }}>
{metrics?.system_message && (
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '11px', color: '#888', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
System Message
</div>
<pre
style={{
fontSize: '10px',
fontFamily: 'JetBrains Mono, monospace',
lineHeight: '1.5',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
color: '#60a5fa',
background: '#0f0f0f',
padding: '12px',
borderRadius: '4px',
border: '1px solid #1f1f1f',
maxHeight: '300px',
overflow: 'auto'
}}
>
{metrics.system_message}
</pre>
</div>
)}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '11px', color: '#888', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Snapshot JSON
</div>
<pre
style={{
fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace',
lineHeight: '1.6',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
margin: 0
}}
dangerouslySetInnerHTML={{ __html: highlightJson(formatJson(log.snapshot_json)) }}
/>
</div>
{log.enriched_summary && (
<div>
<div style={{ fontSize: '11px', color: '#888', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Enriched Summary
</div>
<div style={{ fontSize: '12px', color: '#ccc', lineHeight: '1.6' }}>
{log.enriched_summary}
</div>
</div>
)}
</td>
</tr>
)}
</>
);
}
+303
View File
@@ -0,0 +1,303 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from 'react';
import { LogEntry, LogsResponse } from '@/types/logs';
import LogsRow from './LogsRow';
type AppliedFilters = {
threadId?: string;
traceId?: string;
table?: string;
};
export default function LogsViewer() {
const LIMIT = 100;
const [logs, setLogs] = useState<LogEntry[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [inputThreadId, setInputThreadId] = useState('');
const [inputTraceId, setInputTraceId] = useState('');
const [inputTable, setInputTable] = useState('');
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({});
const filtersActive = useMemo(
() => Boolean(appliedFilters.threadId || appliedFilters.traceId || appliedFilters.table),
[appliedFilters]
);
const fetchLogs = useCallback(
async (pageNum: number, filters: AppliedFilters) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
params.set('page', pageNum.toString());
params.set('limit', LIMIT.toString());
if (filters.threadId) params.set('threadId', filters.threadId);
if (filters.traceId) params.set('traceId', filters.traceId);
if (filters.table) params.set('table', filters.table);
const response = await fetch(`/api/logs?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch logs');
}
const data: LogsResponse = await response.json();
setLogs(data.logs);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setLogs([]);
} finally {
setLoading(false);
}
},
[LIMIT]
);
useEffect(() => {
fetchLogs(page, appliedFilters);
}, [page, appliedFilters, fetchLogs]);
const handleApplyFilters = () => {
const nextFilters: AppliedFilters = {
threadId: inputThreadId.trim() || undefined,
traceId: inputTraceId.trim() || undefined,
table: inputTable.trim() || undefined,
};
setAppliedFilters(nextFilters);
setPage(1);
};
const handleClearFilters = () => {
setInputThreadId('');
setInputTraceId('');
setInputTable('');
setAppliedFilters({});
setPage(1);
};
const handlePrevious = () => {
setPage(prev => (prev > 1 ? prev - 1 : prev));
};
const handleNext = () => {
if (logs.length === LIMIT) {
setPage(prev => prev + 1);
}
};
if (loading) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
Loading logs...
</div>
);
}
if (error) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
Error: {error}
</div>
);
}
if (logs.length === 0) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
No logs found
</div>
);
}
const isFirstPage = page === 1;
const isLastPage = logs.length < LIMIT;
const filterStatus = appliedFilters.threadId
? `Thread: ${appliedFilters.threadId}`
: appliedFilters.traceId
? `Trace: ${appliedFilters.traceId}`
: appliedFilters.table
? `Table: ${appliedFilters.table}`
: `Page ${page}`;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
padding: '16px 24px',
borderBottom: '1px solid #2a2a2a',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
Thread ID
<input
value={inputThreadId}
onChange={(e) => setInputThreadId(e.target.value)}
placeholder="ra-h-node-4326-session_..."
style={{
background: '#0f0f0f',
border: '1px solid #2a2a2a',
color: '#ddd',
padding: '6px 10px',
borderRadius: '4px',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '12px',
minWidth: '240px',
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
Trace ID
<input
value={inputTraceId}
onChange={(e) => setInputTraceId(e.target.value)}
placeholder="trace uuid"
style={{
background: '#0f0f0f',
border: '1px solid #2a2a2a',
color: '#ddd',
padding: '6px 10px',
borderRadius: '4px',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '12px',
minWidth: '200px',
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
Table
<input
value={inputTable}
onChange={(e) => setInputTable(e.target.value)}
placeholder="nodes | edges | chats"
style={{
background: '#0f0f0f',
border: '1px solid #2a2a2a',
color: '#ddd',
padding: '6px 10px',
borderRadius: '4px',
fontFamily: 'JetBrains Mono, monospace',
fontSize: '12px',
minWidth: '160px',
}}
/>
</label>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleApplyFilters}
style={{
padding: '8px 16px',
background: '#22c55e33',
border: '1px solid #22c55e66',
borderRadius: '4px',
color: '#22c55e',
cursor: 'pointer',
fontSize: '12px',
fontFamily: 'JetBrains Mono, monospace',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#22c55e55';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#22c55e33';
}}
>
Apply
</button>
<button
onClick={handleClearFilters}
style={{
padding: '8px 16px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
color: '#ccc',
cursor: 'pointer',
fontSize: '12px',
fontFamily: 'JetBrains Mono, monospace',
}}
>
Clear
</button>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: '12px', color: '#666' }}>{filterStatus}</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handlePrevious}
disabled={isFirstPage || filtersActive}
style={{
padding: '8px 16px',
background: isFirstPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
border: '1px solid #333',
borderRadius: '4px',
color: isFirstPage || filtersActive ? '#555' : '#ccc',
cursor: isFirstPage || filtersActive ? 'not-allowed' : 'pointer',
fontSize: '12px',
fontFamily: 'JetBrains Mono, monospace',
}}
>
Previous
</button>
<button
onClick={handleNext}
disabled={isLastPage || filtersActive}
style={{
padding: '8px 16px',
background: isLastPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
border: '1px solid #333',
borderRadius: '4px',
color: isLastPage || filtersActive ? '#555' : '#ccc',
cursor: isLastPage || filtersActive ? 'not-allowed' : 'pointer',
fontSize: '12px',
fontFamily: 'JetBrains Mono, monospace',
}}
>
Next
</button>
</div>
</div>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
<tr>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '60px' }}>
ID
</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '180px' }}>
Timestamp
</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '100px' }}>
Table
</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '80px' }}>
Action
</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a' }}>
Summary
</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a', width: '80px' }}>
Row ID
</th>
</tr>
</thead>
<tbody>
{logs.map((log, index) => (
<LogsRow key={log.id} log={log} isEven={index % 2 === 0} />
))}
</tbody>
</table>
</div>
</div>
);
}
+455
View File
@@ -0,0 +1,455 @@
"use client";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { Folder, Map as MapIcon } from 'lucide-react';
import type { Edge, Node } from '@/types/database';
interface GraphNode extends Node {
edge_count?: number;
x: number;
y: number;
radius: number;
tier: number;
}
interface PopularDimension {
dimension: string;
count: number;
isPriority: boolean;
}
interface TransformState {
x: number;
y: number;
scale: number;
}
const LIMIT = 400;
const PRIMARY_NODE_LIMIT = 150;
export default function MapViewer() {
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerSize, setContainerSize] = useState({ width: 800, height: 600 });
const [nodes, setNodes] = useState<Node[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const [lockedDimensions, setLockedDimensions] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [transform, setTransform] = useState<TransformState>({ x: 0, y: 0, scale: 1 });
const [hoverNode, setHoverNode] = useState<Node | null>(null);
useEffect(() => {
const observer = new ResizeObserver(entries => {
const entry = entries[0];
if (entry?.contentRect) {
setContainerSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, []);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const [nodesRes, edgesRes, dimensionsRes] = await Promise.all([
fetch(`/api/nodes?limit=${LIMIT}&sortBy=edges`),
fetch('/api/edges'),
fetch('/api/dimensions/popular'),
]);
if (!nodesRes.ok || !edgesRes.ok) {
throw new Error('Failed to load knowledge graph data');
}
const nodesPayload = await nodesRes.json();
const edgesPayload = await edgesRes.json();
setNodes(nodesPayload.data || []);
setEdges(edgesPayload.data || []);
if (dimensionsRes.ok) {
const dimPayload = await dimensionsRes.json();
if (dimPayload.success) {
const priority: PopularDimension[] = dimPayload.data;
setLockedDimensions(new Set(priority.filter(d => d.isPriority).map(d => d.dimension)));
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const sortedNodes = useMemo(() => {
return [...nodes].sort((a, b) => {
const aLocked = a.dimensions?.some(dim => lockedDimensions.has(dim));
const bLocked = b.dimensions?.some(dim => lockedDimensions.has(dim));
if (aLocked !== bLocked) {
return aLocked ? -1 : 1;
}
return (b.edge_count ?? 0) - (a.edge_count ?? 0);
});
}, [nodes, lockedDimensions]);
const contextHubIds = useMemo(() => {
return new Set(sortedNodes.slice(0, 10).map(node => node.id));
}, [sortedNodes]);
const graphNodes = useMemo<GraphNode[]>(() => {
if (sortedNodes.length === 0) return [];
const { width, height } = containerSize;
const centerX = width / 2;
const centerY = height / 2;
const primaryNodes = sortedNodes.slice(0, PRIMARY_NODE_LIMIT);
const secondaryNodes = sortedNodes.slice(PRIMARY_NODE_LIMIT);
const jitter = (index: number, span: number) => ((index % span) / span) * 40 - 20;
const positionedPrimary = primaryNodes.map((node, index) => {
const isContextHub = contextHubIds.has(node.id);
const isLocked = node.dimensions?.some(dim => lockedDimensions.has(dim));
const tier = isContextHub ? 0 : isLocked ? 1 : 2;
const baseRadius = [60, 200, 340][tier];
const radius = baseRadius + Math.min(node.edge_count || 0, 80);
const angle = ((index % 80) / 80) * Math.PI * 2;
const x = centerX + Math.cos(angle) * radius + jitter(index, 5);
const y = centerY + Math.sin(angle) * radius * 0.7 + jitter(index, 7);
const size = tier === 0 ? 16 : tier === 1 ? 12 : 8;
const radiusScaled = size + Math.log((node.edge_count || 1) + 1) * (tier === 2 ? 1.5 : 2.5);
return {
...node,
x,
y,
radius: radiusScaled,
tier,
};
});
const positionedSecondary = secondaryNodes.map((node, index) => {
const angle = (index / secondaryNodes.length) * Math.PI * 2;
const outerRadius = Math.max(width, height) * 0.55;
return {
...node,
x: centerX + Math.cos(angle) * outerRadius,
y: centerY + Math.sin(angle) * outerRadius,
radius: 2,
tier: 3,
};
});
return [...positionedPrimary, ...positionedSecondary];
}, [sortedNodes, lockedDimensions, containerSize]);
const graphEdges = useMemo(() => {
if (graphNodes.length === 0 || edges.length === 0) return [];
const nodeMap = new Map<number, GraphNode>();
graphNodes.forEach(node => nodeMap.set(node.id, node));
const weightedEdges = edges
.map(edge => {
const source = nodeMap.get(edge.from_node_id);
const target = nodeMap.get(edge.to_node_id);
if (!source || !target) return null;
const weight = (source.edge_count || 0) + (target.edge_count || 0);
return { id: edge.id, source, target, weight };
})
.filter(Boolean) as Array<{ id: number; source: GraphNode; target: GraphNode; weight: number }>;
return weightedEdges
.sort((a, b) => b.weight - a.weight)
.slice(0, 800);
}, [edges, graphNodes]);
const handleZoom = (direction: 'in' | 'out' | 'reset') => {
if (direction === 'reset') {
setTransform({ x: 0, y: 0, scale: 1 });
return;
}
setTransform(prev => ({
...prev,
scale: direction === 'in' ? Math.min(prev.scale + 0.2, 2.5) : Math.max(prev.scale - 0.2, 0.6),
}));
};
const handlePanStart = (event: React.PointerEvent<SVGRectElement>) => {
const startX = event.clientX;
const startY = event.clientY;
const originX = transform.x;
const originY = transform.y;
const handleMove = (moveEvent: PointerEvent) => {
setTransform(prev => ({
...prev,
x: originX + (moveEvent.clientX - startX),
y: originY + (moveEvent.clientY - startY),
}));
};
const handleUp = () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
};
if (loading) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
Generating map...
</div>
);
}
if (error) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
Error: {error}
</div>
);
}
if (graphNodes.length === 0) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
Not enough nodes to render a map yet
</div>
);
}
return (
<div ref={containerRef} style={{ position: 'relative', height: '100%', background: '#050505' }}>
{/* Controls */}
<div
style={{
position: 'absolute',
top: 16,
right: 16,
display: 'flex',
gap: '8px',
zIndex: 2,
}}
>
<button
onClick={() => handleZoom('in')}
style={controlButtonStyle}
title="Zoom in"
>
+
</button>
<button
onClick={() => handleZoom('out')}
style={controlButtonStyle}
title="Zoom out"
>
</button>
<button
onClick={() => handleZoom('reset')}
style={controlButtonStyle}
title="Reset view"
>
</button>
</div>
{/* Legend */}
<div
style={{
position: 'absolute',
bottom: 16,
left: 16,
background: 'rgba(8,8,8,0.9)',
border: '1px solid #1f1f1f',
borderRadius: '8px',
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
fontSize: '12px',
color: '#bbb',
zIndex: 2,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 600 }}>
<MapIcon size={14} /> Legend
</div>
<LegendRow color="#fcd34d" label="Auto-context hub" />
<LegendRow color="#7de8a5" label="Locked dimension" icon={<Folder size={12} color="#7de8a5" />} />
<LegendRow color="#cbd5f5" label="Regular node" />
<div style={{ fontSize: '11px', color: '#666' }}>Node size increases with edge count</div>
</div>
{/* Hover tooltip */}
{hoverNode && (
<div
style={{
position: 'absolute',
bottom: 16,
right: 16,
maxWidth: '260px',
background: 'rgba(10,10,10,0.95)',
border: '1px solid #1f1f1f',
borderRadius: '8px',
padding: '12px',
fontSize: '12px',
color: '#eee',
zIndex: 2,
}}
>
<div style={{ fontWeight: 600 }}>{hoverNode.title || 'Untitled node'}</div>
<div style={{ fontSize: '11px', color: '#666', marginBottom: '6px' }}>ID: {hoverNode.id}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<span>Edges: {hoverNode.edge_count ?? 0}</span>
<span>Auto-context hub: {contextHubIds.has(hoverNode.id) ? 'Yes' : 'No'}</span>
<span style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{(hoverNode.dimensions || []).slice(0, 3).map(dimension => (
<span
key={`${hoverNode.id}-${dimension}`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 6px',
borderRadius: '999px',
background: '#0f1a12',
border: '1px solid #1f3425',
}}
>
<Folder size={10} />
{dimension}
</span>
))}
</span>
</div>
</div>
)}
<svg width="100%" height="100%" style={{ display: 'block' }}>
<rect
width="100%"
height="100%"
fill="transparent"
style={{ cursor: 'grab' }}
onPointerDown={handlePanStart}
/>
<g transform={`translate(${transform.x} ${transform.y}) scale(${transform.scale})`}>
{graphEdges.map(edge => {
const thickness = Math.min(3, 0.5 + edge.weight / 300);
const opacity = Math.min(0.7, 0.2 + edge.weight / 800);
return (
<line
key={edge.id}
x1={edge.source.x}
y1={edge.source.y}
x2={edge.target.x}
y2={edge.target.y}
stroke="#1f2933"
strokeWidth={thickness}
strokeOpacity={opacity}
/>
);
})}
{graphNodes.map(node => {
const isContextHub = contextHubIds.has(node.id);
const isLocked = node.dimensions?.some(dim => lockedDimensions.has(dim));
const fill = isContextHub ? '#fcd34d' : isLocked ? '#7de8a5' : node.tier === 3 ? '#334155' : '#cbd5f5';
const stroke = isContextHub ? '#fbbf24' : isLocked ? '#4ade80' : node.tier === 3 ? '#1e293b' : '#94a3b8';
const showLabel = node.tier < 3 && transform.scale > 0.8;
return (
<g
key={node.id}
onMouseEnter={() => setHoverNode(node)}
onMouseLeave={() => setHoverNode(null)}
onClick={() => {
if (node.tier === 3) return;
setTransform(prev => {
const nextScale = Math.min(2.5, Math.max(prev.scale, 1.4));
return {
scale: nextScale,
x: containerSize.width / 2 - node.x * nextScale,
y: containerSize.height / 2 - node.y * nextScale,
};
});
}}
style={{ cursor: node.tier < 3 ? 'pointer' : 'default' }}
>
<circle
cx={node.x}
cy={node.y}
r={node.radius}
fill={fill}
fillOpacity={node.tier === 3 ? 0.4 : isContextHub ? 0.95 : 0.75}
stroke={stroke}
strokeWidth={node.tier === 3 ? 0.5 : isContextHub ? 2.5 : 1.5}
opacity={node.tier === 3 ? 0.6 : 0.95}
/>
{showLabel && (
<text
x={node.x}
y={node.y + node.radius + 12}
textAnchor="middle"
fill="#94a3b8"
fontSize={10}
fontWeight={500}
>
{(node.title || 'Untitled').slice(0, 24)}
</text>
)}
</g>
);
})}
</g>
</svg>
</div>
);
}
function LegendRow({ color, label, icon }: { color: string; label: string; icon?: ReactNode }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
width: '12px',
height: '12px',
borderRadius: '999px',
background: color,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: '#000',
fontSize: '9px',
}}
>
{icon}
</span>
{label}
</div>
);
}
const controlButtonStyle: CSSProperties = {
width: '32px',
height: '32px',
borderRadius: '999px',
border: '1px solid #1f1f1f',
background: 'rgba(8,8,8,0.85)',
color: '#eee',
fontSize: '16px',
cursor: 'pointer',
};
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { User } from 'lucide-react';
interface SettingsCogProps {
onClick: () => void;
}
export default function SettingsCog({ onClick }: SettingsCogProps) {
return (
<button
onClick={onClick}
style={{
position: 'absolute',
bottom: '16px',
left: '16px',
width: '40px',
height: '40px',
background: '#1a1a1a',
border: 'none', /* Remove border for cleaner look */
borderRadius: '50%', /* Make it circular like an avatar */
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
zIndex: 100
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#232323';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1a1a1a';
}}
title="User Settings"
>
<User
size={20}
color="#666"
strokeWidth={1.5}
/>
</button>
);
}
+362
View File
@@ -0,0 +1,362 @@
"use client";
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import LogsViewer from './LogsViewer';
import ToolsViewer from './ToolsViewer';
import WorkflowsViewer from './WorkflowsViewer';
import ApiKeysViewer from './ApiKeysViewer';
import DatabaseViewer from './DatabaseViewer';
import MapViewer from './MapViewer';
import ExternalAgentsPanel from './ExternalAgentsPanel';
import ContextViewer from './ContextViewer';
import { apiKeyService } from '@/services/storage/apiKeys';
export type SettingsTab =
| 'logs'
| 'tools'
| 'workflows'
| 'apikeys'
| 'database'
| 'map'
| 'context'
| 'agents';
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
initialTab?: SettingsTab;
}
type TabType = SettingsTab;
export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsModalProps) {
// 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';
}
return 'logs';
};
const [activeTab, setActiveTab] = useState<TabType>(getDefaultTab());
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
useEffect(() => {
if (!isOpen || !initialTab) return;
setActiveTab(initialTab);
}, [initialTab, isOpen]);
if (!isOpen) return null;
const modalContent = (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}
onClick={onClose}
>
<div
style={{
width: '80vw',
height: '85vh',
background: '#0f0f0f',
border: '1px solid #2a2a2a',
borderRadius: '8px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.6)',
display: 'flex',
overflow: 'hidden'
}}
onClick={(e) => e.stopPropagation()}
>
{/* Sidebar */}
<div
style={{
width: '20%',
background: '#0a0a0a',
borderRight: '1px solid #2a2a2a',
display: 'flex',
flexDirection: 'column',
padding: '24px 0'
}}
>
<div
style={{
padding: '0 24px',
marginBottom: '24px',
fontSize: '18px',
fontWeight: '600',
color: '#fff'
}}
>
Settings
</div>
<nav>
<div
onClick={() => setActiveTab('logs')}
style={{
padding: '12px 24px',
fontSize: '14px',
color: activeTab === 'logs' ? '#fff' : '#888',
background: activeTab === 'logs' ? '#1a3a2a' : 'transparent',
borderLeft: activeTab === 'logs' ? '3px solid #22c55e' : '3px solid transparent',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
Logs
</div>
<div
onClick={() => setActiveTab('tools')}
style={{
padding: '12px 24px',
fontSize: '14px',
color: activeTab === 'tools' ? '#fff' : '#888',
background: activeTab === 'tools' ? '#1a3a2a' : 'transparent',
borderLeft: activeTab === 'tools' ? '3px solid #22c55e' : '3px solid transparent',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
Tools
</div>
<div
onClick={() => setActiveTab('workflows')}
style={{
padding: '12px 24px',
fontSize: '14px',
color: activeTab === 'workflows' ? '#fff' : '#888',
background: activeTab === 'workflows' ? '#1a3a2a' : 'transparent',
borderLeft: activeTab === 'workflows' ? '3px solid #22c55e' : '3px solid transparent',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
Workflows
</div>
<div
onClick={() => setActiveTab('apikeys')}
style={{
padding: '12px 24px',
fontSize: '14px',
color: activeTab === 'apikeys' ? '#fff' : '#888',
background: activeTab === 'apikeys' ? '#1a3a2a' : 'transparent',
borderLeft: activeTab === 'apikeys' ? '3px solid #22c55e' : '3px solid transparent',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
API Keys
</div>
<div
onClick={() => setActiveTab('database')}
style={{
padding: '12px 24px',
fontSize: '14px',
color: activeTab === 'database' ? '#fff' : '#888',
background: activeTab === 'database' ? '#1a3a2a' : 'transparent',
borderLeft: activeTab === 'database' ? '3px solid #22c55e' : '3px solid transparent',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
Database
</div>
<div
onClick={() => setActiveTab('context')}
style={{
padding: '12px 24px',
fontSize: '14px',
color: activeTab === 'context' ? '#fff' : '#888',
background: activeTab === 'context' ? '#1a3a2a' : 'transparent',
borderLeft: activeTab === 'context' ? '3px solid #22c55e' : '3px solid transparent',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
Context
</div>
<div
onClick={() => setActiveTab('map')}
style={{
padding: '12px 24px',
fontSize: '14px',
color: activeTab === 'map' ? '#fff' : '#888',
background: activeTab === 'map' ? '#1a3a2a' : 'transparent',
borderLeft: activeTab === 'map' ? '3px solid #22c55e' : '3px solid transparent',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
Map
</div>
<div
onClick={() => setActiveTab('agents')}
style={{
padding: '12px 24px',
fontSize: '14px',
color: activeTab === 'agents' ? '#fff' : '#888',
background: activeTab === 'agents' ? '#1a3a2a' : 'transparent',
borderLeft: activeTab === 'agents' ? '3px solid #22c55e' : '3px solid transparent',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
External Agents
</div>
<div
style={{
padding: '12px 24px',
fontSize: '14px',
color: '#888',
opacity: 0.4,
cursor: 'not-allowed'
}}
>
Backups
</div>
<div
style={{
padding: '12px 24px',
fontSize: '14px',
color: '#888',
opacity: 0.4,
cursor: 'not-allowed'
}}
>
Preferences
</div>
</nav>
<div
style={{
marginTop: 'auto',
padding: '24px',
borderTop: '1px solid #1f2937',
display: 'flex',
flexDirection: 'column',
gap: '12px'
}}
>
<div
style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#64748b'
}}
>
Local Mode
</div>
<p
style={{
fontSize: '13px',
color: '#94a3b8',
margin: 0,
lineHeight: 1.5
}}
>
This open-source build runs entirely on your machine. Add keys via the API Keys tab to unlock every agent.
</p>
</div>
</div>
{/* Content Area */}
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column'
}}
>
{/* Header */}
<div
style={{
padding: '16px 24px',
borderBottom: '1px solid #2a2a2a',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<h2
style={{
margin: 0,
fontSize: '16px',
fontWeight: '600',
color: '#fff'
}}
>
{activeTab === 'logs' && 'System Logs'}
{activeTab === 'tools' && 'Tools'}
{activeTab === 'workflows' && 'Workflows'}
{activeTab === 'apikeys' && 'API Keys'}
{activeTab === 'database' && 'Knowledge Database'}
{activeTab === 'context' && 'Auto-Context'}
{activeTab === 'map' && 'Knowledge Map'}
{activeTab === 'agents' && 'External Agents'}
</h2>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: '#888',
cursor: 'pointer',
fontSize: '24px',
lineHeight: 1,
padding: '4px 8px',
transition: 'color 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#888';
}}
title="Close (ESC)"
>
×
</button>
</div>
{/* Content */}
<div style={{ flex: 1, overflow: 'hidden' }}>
{activeTab === 'logs' && <LogsViewer key={isOpen ? 'open' : 'closed'} />}
{activeTab === 'tools' && <ToolsViewer />}
{activeTab === 'workflows' && <WorkflowsViewer />}
{activeTab === 'apikeys' && <ApiKeysViewer />}
{activeTab === 'database' && <DatabaseViewer />}
{activeTab === 'context' && <ContextViewer />}
{activeTab === 'map' && <MapViewer />}
{activeTab === 'agents' && <ExternalAgentsPanel />}
</div>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useState, useEffect } from 'react';
interface ToolGroup {
id: string;
name: string;
description: string;
icon: string;
color: string;
}
export default function ToolsViewer() {
const [groups, setGroups] = useState<Record<string, ToolGroup>>({});
const [groupedTools, setGroupedTools] = useState<Record<string, { name: string; description: string }[]>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadTools = async () => {
try {
const response = await fetch('/api/tools');
const result = await response.json();
if (result.success) {
setGroups(result.data.groups);
setGroupedTools(result.data.tools);
}
} catch (error) {
console.error('Failed to load tools:', error);
} finally {
setLoading(false);
}
};
loadTools();
}, []);
if (loading) {
return (
<div style={{ padding: '24px', textAlign: 'center', color: '#888' }}>
Loading tools...
</div>
);
}
return (
<div style={{ padding: '24px', overflowY: 'auto', height: '100%' }}>
<div style={{ marginBottom: '32px' }}>
<p style={{ color: '#888', fontSize: '14px', marginBottom: '24px' }}>
Read-only view of all tools available in the system, grouped by function.
</p>
{Object.entries(groups).map(([groupId, group]) => {
const tools = groupedTools[groupId] || [];
if (tools.length === 0) return null;
return (
<div key={groupId} style={{ marginBottom: '32px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '16px',
paddingBottom: '8px',
borderBottom: `2px solid ${group.color}`,
}}
>
<span style={{ color: group.color, fontSize: '16px' }}>{group.icon}</span>
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600', color: '#fff' }}>
{group.name}
</h3>
<span style={{ fontSize: '13px', color: '#666', marginLeft: '8px' }}>
({tools.length} tools)
</span>
</div>
<div style={{ fontSize: '13px', color: '#888', marginBottom: '16px', fontStyle: 'italic' }}>
{group.description}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{tools.map((tool) => (
<div
key={tool.name}
style={{
padding: '12px 16px',
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: '6px',
}}
>
<div style={{ fontFamily: 'monospace', fontSize: '13px', color: '#3b82f6', marginBottom: '6px' }}>
{tool.name}
</div>
<div style={{ fontSize: '13px', color: '#aaa', lineHeight: '1.5' }}>
{tool.description}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { useState, useEffect } from 'react';
interface WorkflowDefinition {
id: number;
key: string;
displayName: string;
description: string;
instructions: string;
enabled: boolean;
requiresFocusedNode: boolean;
primaryActor: 'oracle' | 'main';
expectedOutcome?: string;
}
export default function WorkflowsViewer() {
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadWorkflows = async () => {
try {
const response = await fetch('/api/workflows');
const result = await response.json();
if (result.success) {
setWorkflows(result.data);
}
} catch (error) {
console.error('Failed to load workflows:', error);
} finally {
setLoading(false);
}
};
loadWorkflows();
}, []);
if (loading) {
return (
<div style={{ padding: '24px', textAlign: 'center', color: '#888' }}>
Loading workflows...
</div>
);
}
return (
<div style={{ padding: '24px', overflowY: 'auto', height: '100%' }}>
<div style={{ marginBottom: '32px' }}>
<p style={{ color: '#888', fontSize: '14px', marginBottom: '24px' }}>
Read-only view of all predefined workflows. Click to expand instructions.
</p>
{workflows.length === 0 && (
<div style={{ color: '#666', fontSize: '14px', textAlign: 'center', padding: '32px' }}>
No workflows defined yet.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{workflows.map((workflow) => {
const isExpanded = expandedId === workflow.id;
return (
<div
key={workflow.id}
style={{
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: '8px',
overflow: 'hidden',
}}
>
{/* Header */}
<div
onClick={() => setExpandedId(isExpanded ? null : workflow.id)}
style={{
padding: '16px 20px',
cursor: 'pointer',
borderBottom: isExpanded ? '1px solid #2a2a2a' : 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600', color: '#fff' }}>
{workflow.displayName}
</h3>
<span style={{ fontFamily: 'monospace', fontSize: '12px', color: '#666' }}>
{workflow.key}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{workflow.enabled ? (
<span style={{ fontSize: '12px', color: '#10b981', padding: '4px 8px', background: '#10b98120', borderRadius: '4px' }}>
Enabled
</span>
) : (
<span style={{ fontSize: '12px', color: '#ef4444', padding: '4px 8px', background: '#ef444420', borderRadius: '4px' }}>
Disabled
</span>
)}
<span style={{ fontSize: '18px', color: '#666', transition: 'transform 0.2s', transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)' }}>
</span>
</div>
</div>
<div style={{ fontSize: '14px', color: '#aaa', marginBottom: '12px' }}>
{workflow.description}
</div>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
<div style={{ fontSize: '12px' }}>
<span style={{ color: '#666' }}>Requires Node:</span>{' '}
<span style={{ color: workflow.requiresFocusedNode ? '#f59e0b' : '#666' }}>
{workflow.requiresFocusedNode ? 'Yes' : 'No'}
</span>
</div>
<div style={{ fontSize: '12px' }}>
<span style={{ color: '#666' }}>Primary Actor:</span>{' '}
<span style={{ color: '#8b5cf6' }}>
{workflow.primaryActor === 'oracle' ? 'Wise RA-H (GPT-5)' : 'Main RA-H'}
</span>
</div>
{workflow.expectedOutcome && (
<div style={{ fontSize: '12px', flex: '1 1 100%' }}>
<span style={{ color: '#666' }}>Expected Outcome:</span>{' '}
<span style={{ color: '#aaa' }}>
{workflow.expectedOutcome}
</span>
</div>
)}
</div>
</div>
{/* Expanded Instructions */}
{isExpanded && (
<div style={{ padding: '20px', background: '#0f0f0f' }}>
<div style={{ marginBottom: '12px', fontSize: '13px', fontWeight: '600', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Workflow Instructions
</div>
<div
style={{
fontFamily: 'monospace',
fontSize: '12px',
color: '#ddd',
whiteSpace: 'pre-wrap',
lineHeight: '1.6',
padding: '16px',
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: '6px',
maxHeight: '400px',
overflowY: 'auto',
}}
>
{workflow.instructions}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}