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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 device—tools 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user