sync: Settings panel redesign + Map visualization overhaul
- Settings viewers: consistent minimal styling across all tabs - MapViewer: cluster layout, node sizing by edge count, transparent flat circles, connected node highlighting - SettingsCog: green ring around settings icon for visibility - All viewers: refined typography, spacing, and card styling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
2f2ef10ec9
commit
ffbd47563e
@@ -1,19 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, type CSSProperties } 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 [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 || '');
|
||||
@@ -22,52 +18,18 @@ export default function ApiKeysViewer() {
|
||||
|
||||
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'}`);
|
||||
}
|
||||
apiKeyService.setOpenAiKey(openaiKey.trim());
|
||||
setStatus(prev => ({ ...prev, openai: 'testing' }));
|
||||
const ok = await apiKeyService.testOpenAiConnection();
|
||||
setStatus(prev => ({ ...prev, openai: ok ? 'connected' : 'failed' }));
|
||||
};
|
||||
|
||||
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 () => {
|
||||
apiKeyService.setAnthropicKey(anthropicKey.trim());
|
||||
setStatus(prev => ({ ...prev, anthropic: 'testing' }));
|
||||
const isConnected = await apiKeyService.testAnthropicConnection();
|
||||
setStatus(prev => ({ ...prev, anthropic: isConnected ? 'connected' : 'failed' }));
|
||||
const ok = await apiKeyService.testAnthropicConnection();
|
||||
setStatus(prev => ({ ...prev, anthropic: ok ? 'connected' : 'failed' }));
|
||||
};
|
||||
|
||||
const handleClearOpenAi = () => {
|
||||
@@ -82,269 +44,203 @@ export default function ApiKeysViewer() {
|
||||
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';
|
||||
}
|
||||
const getStatusLabel = (s: string) => {
|
||||
if (s === 'connected') return { text: 'Connected', color: '#22c55e' };
|
||||
if (s === 'failed') return { text: 'Failed', color: '#ef4444' };
|
||||
if (s === 'testing') return { text: 'Testing...', color: '#6b7280' };
|
||||
return { text: 'Not set', color: '#6b7280' };
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div style={containerStyle}>
|
||||
<p style={descStyle}>Keys are stored locally and never shared.</p>
|
||||
|
||||
{/* 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>
|
||||
<label style={toggleStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showKeys}
|
||||
onChange={(e) => setShowKeys(e.target.checked)}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
Show keys
|
||||
</label>
|
||||
|
||||
{/* 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>
|
||||
{/* OpenAI */}
|
||||
<div style={cardStyle}>
|
||||
<div style={cardHeaderStyle}>
|
||||
<span style={cardTitleStyle}>OpenAI</span>
|
||||
<span style={{ fontSize: 12, color: getStatusLabel(status.openai).color }}>
|
||||
{getStatusLabel(status.openai).text}
|
||||
</span>
|
||||
</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' }}>
|
||||
<input
|
||||
type={showKeys ? 'text' : 'password'}
|
||||
value={openaiKey}
|
||||
onChange={(e) => setOpenaiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={buttonRowStyle}>
|
||||
<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
|
||||
}}
|
||||
style={{ ...btnPrimaryStyle, opacity: openaiKey.trim() ? 1 : 0.4 }}
|
||||
>
|
||||
Save & Test
|
||||
Save
|
||||
</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
|
||||
}}
|
||||
style={{ ...btnSecondaryStyle, opacity: openaiKey ? 1 : 0.4 }}
|
||||
>
|
||||
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>
|
||||
{/* Anthropic */}
|
||||
<div style={cardStyle}>
|
||||
<div style={cardHeaderStyle}>
|
||||
<span style={cardTitleStyle}>Anthropic</span>
|
||||
<span style={{ fontSize: 12, color: getStatusLabel(status.anthropic).color }}>
|
||||
{getStatusLabel(status.anthropic).text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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' }}>
|
||||
<input
|
||||
type={showKeys ? 'text' : 'password'}
|
||||
value={anthropicKey}
|
||||
onChange={(e) => setAnthropicKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={buttonRowStyle}>
|
||||
<button
|
||||
onClick={handleSaveAnthropic}
|
||||
disabled={!anthropicKey.trim()}
|
||||
style={{
|
||||
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
|
||||
}}
|
||||
style={{ ...btnPrimaryStyle, opacity: anthropicKey.trim() ? 1 : 0.4 }}
|
||||
>
|
||||
Save & Test
|
||||
Save
|
||||
</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
|
||||
}}
|
||||
style={{ ...btnSecondaryStyle, opacity: anthropicKey ? 1 : 0.4 }}
|
||||
>
|
||||
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>
|
||||
{/* Help */}
|
||||
<div style={helpStyle}>
|
||||
<div style={{ marginBottom: 8 }}>Get keys from:</div>
|
||||
<div>
|
||||
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noreferrer" style={linkStyle}>
|
||||
OpenAI →
|
||||
</a>
|
||||
<span style={{ margin: '0 12px', color: '#4b5563' }}>·</span>
|
||||
<a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noreferrer" style={linkStyle}>
|
||||
Anthropic →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle: CSSProperties = {
|
||||
padding: 24,
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
};
|
||||
|
||||
const descStyle: CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: '#6b7280',
|
||||
marginBottom: 16,
|
||||
};
|
||||
|
||||
const toggleStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
cursor: 'pointer',
|
||||
marginBottom: 20,
|
||||
};
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
};
|
||||
|
||||
const cardHeaderStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
};
|
||||
|
||||
const cardTitleStyle: CSSProperties = {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: '#e5e7eb',
|
||||
};
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: 6,
|
||||
color: '#e5e7eb',
|
||||
marginBottom: 12,
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
const buttonRowStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const btnPrimaryStyle: CSSProperties = {
|
||||
padding: '8px 14px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
background: '#22c55e',
|
||||
color: '#052e16',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const btnSecondaryStyle: CSSProperties = {
|
||||
padding: '8px 14px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
background: 'rgba(255, 255, 255, 0.06)',
|
||||
color: '#9ca3af',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const helpStyle: CSSProperties = {
|
||||
marginTop: 8,
|
||||
padding: 16,
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
};
|
||||
|
||||
const linkStyle: CSSProperties = {
|
||||
color: '#22c55e',
|
||||
textDecoration: 'none',
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
interface AutoContextSettings {
|
||||
@@ -14,49 +14,33 @@ interface NodeWithMetrics extends Node {
|
||||
|
||||
export default function ContextViewer() {
|
||||
const [nodes, setNodes] = useState<NodeWithMetrics[]>([]);
|
||||
const [autoContextEnabled, setAutoContextEnabled] = useState(false);
|
||||
const [lastMigrated, setLastMigrated] = useState<string | undefined>();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
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();
|
||||
const res = await fetch('/api/nodes?sortBy=edges&limit=10');
|
||||
const payload = await res.json();
|
||||
setNodes(payload.data || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Unable to load top nodes.');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} 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;
|
||||
};
|
||||
const res = await fetch('/api/system/auto-context');
|
||||
const payload = await res.json() as { success: boolean; data?: AutoContextSettings };
|
||||
if (payload.success && payload.data) {
|
||||
setAutoContextEnabled(Boolean(payload.data.autoContextEnabled));
|
||||
setLastMigrated(payload.data.lastPinnedMigration);
|
||||
setEnabled(payload.data.autoContextEnabled);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Unable to load auto-context settings.');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoadingSettings(false);
|
||||
}
|
||||
@@ -66,176 +50,135 @@ export default function ContextViewer() {
|
||||
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', {
|
||||
const res = await fetch('/api/system/auto-context', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoContextEnabled: !autoContextEnabled }),
|
||||
body: JSON.stringify({ autoContextEnabled: !enabled }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
success: boolean;
|
||||
data?: AutoContextSettings;
|
||||
};
|
||||
|
||||
const payload = await res.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');
|
||||
setEnabled(payload.data.autoContextEnabled);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update auto-context toggle', err);
|
||||
setError('Unable to update setting. Please try again.');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} 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={containerStyle}>
|
||||
<p style={descStyle}>
|
||||
Top 10 most-connected nodes are added to background context for chats and workflows.
|
||||
</p>
|
||||
|
||||
<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}
|
||||
{/* Toggle */}
|
||||
<div style={cardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={labelStyle}>Auto-Context</div>
|
||||
<div style={subLabelStyle}>
|
||||
{loadingSettings ? 'Loading...' : enabled ? 'Enabled' : 'Disabled'}
|
||||
</div>
|
||||
</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
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={loadingSettings || saving}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
left: autoContextEnabled ? '32px' : '4px',
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
transition: 'left 0.2s ease',
|
||||
...toggleStyle,
|
||||
background: enabled ? '#22c55e' : 'rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastMigrated && (
|
||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '16px' }}>
|
||||
Auto-enabled because you previously pinned nodes · {new Date(lastMigrated).toLocaleString()}
|
||||
>
|
||||
<span style={{
|
||||
...toggleKnobStyle,
|
||||
left: enabled ? 26 : 4,
|
||||
}} />
|
||||
</button>
|
||||
</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}
|
||||
{/* Nodes List */}
|
||||
<div style={labelStyle}>Top Nodes</div>
|
||||
{loadingNodes ? (
|
||||
<div style={mutedStyle}>Loading...</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div style={mutedStyle}>No connected nodes yet.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{nodes.map((node) => (
|
||||
<div key={node.id} style={nodeCardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={nodeTitleStyle}>{node.title || 'Untitled'}</span>
|
||||
<span style={edgeCountStyle}>{node.edge_count ?? 0}</span>
|
||||
</div>
|
||||
{node.dimensions && node.dimensions.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 6, flexWrap: 'wrap' }}>
|
||||
{node.dimensions.slice(0, 3).map((dim) => (
|
||||
<span key={dim} style={dimTagStyle}>{dim}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
|
||||
const descStyle: CSSProperties = { fontSize: 13, color: '#6b7280', marginBottom: 20, lineHeight: 1.5 };
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: '#e5e7eb', marginBottom: 8 };
|
||||
const subLabelStyle: CSSProperties = { fontSize: 12, color: '#6b7280' };
|
||||
const mutedStyle: CSSProperties = { fontSize: 13, color: '#6b7280' };
|
||||
|
||||
const toggleStyle: CSSProperties = {
|
||||
width: 48,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
transition: 'background 0.15s',
|
||||
};
|
||||
|
||||
const toggleKnobStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
transition: 'left 0.15s',
|
||||
};
|
||||
|
||||
const nodeCardStyle: CSSProperties = {
|
||||
padding: '12px 14px',
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
borderRadius: 6,
|
||||
};
|
||||
|
||||
const nodeTitleStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: '#e5e7eb' };
|
||||
const edgeCountStyle: CSSProperties = { fontSize: 12, color: '#22c55e' };
|
||||
|
||||
const dimTagStyle: CSSProperties = {
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
background: 'rgba(34, 197, 94, 0.1)',
|
||||
color: '#22c55e',
|
||||
};
|
||||
|
||||
@@ -127,86 +127,82 @@ export default function LogsViewer() {
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
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' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#6b7280', gap: '4px' }}>
|
||||
Thread ID
|
||||
<input
|
||||
value={inputThreadId}
|
||||
onChange={(e) => setInputThreadId(e.target.value)}
|
||||
placeholder="ra-h-node-4326-session_..."
|
||||
placeholder="ra-h-node-..."
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
color: '#e5e7eb',
|
||||
padding: '8px 10px',
|
||||
borderRadius: '6px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
minWidth: '240px',
|
||||
minWidth: '200px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#6b7280', gap: '4px' }}>
|
||||
Trace ID
|
||||
<input
|
||||
value={inputTraceId}
|
||||
onChange={(e) => setInputTraceId(e.target.value)}
|
||||
placeholder="trace uuid"
|
||||
placeholder="uuid"
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
color: '#e5e7eb',
|
||||
padding: '8px 10px',
|
||||
borderRadius: '6px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
minWidth: '200px',
|
||||
minWidth: '160px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#6b7280', gap: '4px' }}>
|
||||
Table
|
||||
<input
|
||||
value={inputTable}
|
||||
onChange={(e) => setInputTable(e.target.value)}
|
||||
placeholder="nodes | edges | chats"
|
||||
placeholder="nodes | edges"
|
||||
style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
color: '#e5e7eb',
|
||||
padding: '8px 10px',
|
||||
borderRadius: '6px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
minWidth: '160px',
|
||||
minWidth: '120px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-end' }}>
|
||||
<button
|
||||
onClick={handleApplyFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#22c55e33',
|
||||
border: '1px solid #22c55e66',
|
||||
borderRadius: '4px',
|
||||
color: '#22c55e',
|
||||
padding: '8px 14px',
|
||||
background: '#22c55e',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
color: '#052e16',
|
||||
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';
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
@@ -214,14 +210,13 @@ export default function LogsViewer() {
|
||||
<button
|
||||
onClick={handleClearFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
padding: '8px 14px',
|
||||
background: 'rgba(255, 255, 255, 0.06)',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
color: '#9ca3af',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
@@ -229,20 +224,20 @@ export default function LogsViewer() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>{filterStatus}</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b7280' }}>{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',
|
||||
padding: '8px 14px',
|
||||
background: 'rgba(255, 255, 255, 0.06)',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
color: isFirstPage || filtersActive ? '#4b5563' : '#9ca3af',
|
||||
cursor: isFirstPage || filtersActive ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
opacity: isFirstPage || filtersActive ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
@@ -251,14 +246,14 @@ export default function LogsViewer() {
|
||||
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',
|
||||
padding: '8px 14px',
|
||||
background: 'rgba(255, 255, 255, 0.06)',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
color: isLastPage || filtersActive ? '#4b5563' : '#9ca3af',
|
||||
cursor: isLastPage || filtersActive ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
opacity: isLastPage || filtersActive ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
Next
|
||||
@@ -269,25 +264,25 @@ export default function LogsViewer() {
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
|
||||
<thead style={{ position: 'sticky', top: 0, background: 'rgba(10, 10, 10, 0.95)', 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' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '10px', color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, borderBottom: '1px solid rgba(255, 255, 255, 0.06)', 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' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '10px', color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, borderBottom: '1px solid rgba(255, 255, 255, 0.06)', width: '160px' }}>
|
||||
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' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '10px', color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, borderBottom: '1px solid rgba(255, 255, 255, 0.06)', width: '80px' }}>
|
||||
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' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '10px', color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, borderBottom: '1px solid rgba(255, 255, 255, 0.06)', width: '70px' }}>
|
||||
Action
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '11px', color: '#888', textTransform: 'uppercase', letterSpacing: '0.5px', fontWeight: 'normal', borderBottom: '1px solid #2a2a2a' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '10px', color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, borderBottom: '1px solid rgba(255, 255, 255, 0.06)' }}>
|
||||
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 style={{ padding: '12px 16px', textAlign: 'left', fontSize: '10px', color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, borderBottom: '1px solid rgba(255, 255, 255, 0.06)', width: '70px' }}>
|
||||
Row
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { Folder, Map as MapIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import type { Edge, Node } from '@/types/database';
|
||||
|
||||
interface GraphNode extends Node {
|
||||
@@ -9,23 +8,14 @@ interface GraphNode extends Node {
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
tier: number;
|
||||
}
|
||||
|
||||
interface PopularDimension {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
interface LockedDimension {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface TransformState {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
const LIMIT = 400;
|
||||
const PRIMARY_NODE_LIMIT = 150;
|
||||
const NODE_LIMIT = 200;
|
||||
const LABEL_THRESHOLD = 15; // Top N nodes get labels
|
||||
|
||||
export default function MapViewer() {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -35,9 +25,10 @@ export default function MapViewer() {
|
||||
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);
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
|
||||
|
||||
// Resize observer
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(entries => {
|
||||
const entry = entries[0];
|
||||
@@ -56,19 +47,20 @@ export default function MapViewer() {
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Fetch data
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nodesRes, edgesRes, dimensionsRes] = await Promise.all([
|
||||
fetch(`/api/nodes?limit=${LIMIT}&sortBy=edges`),
|
||||
const [nodesRes, edgesRes, dimsRes] = await Promise.all([
|
||||
fetch(`/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`),
|
||||
fetch('/api/edges'),
|
||||
fetch('/api/dimensions/popular'),
|
||||
fetch('/api/dimensions'),
|
||||
]);
|
||||
|
||||
if (!nodesRes.ok || !edgesRes.ok) {
|
||||
throw new Error('Failed to load knowledge graph data');
|
||||
throw new Error('Failed to load data');
|
||||
}
|
||||
|
||||
const nodesPayload = await nodesRes.json();
|
||||
@@ -77,11 +69,14 @@ export default function MapViewer() {
|
||||
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)));
|
||||
// Get locked dimensions
|
||||
if (dimsRes.ok) {
|
||||
const dimsPayload = await dimsRes.json();
|
||||
if (dimsPayload.success && dimsPayload.data) {
|
||||
const locked = (dimsPayload.data as LockedDimension[])
|
||||
.filter((d: LockedDimension & { is_locked?: boolean }) => d.is_locked)
|
||||
.map((d: LockedDimension) => d.name);
|
||||
setLockedDimensions(new Set(locked));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -94,97 +89,86 @@ export default function MapViewer() {
|
||||
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]);
|
||||
|
||||
// Position nodes in a cluster layout
|
||||
const graphNodes = useMemo<GraphNode[]>(() => {
|
||||
if (sortedNodes.length === 0) return [];
|
||||
if (nodes.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;
|
||||
// Sort by edge count (highest first)
|
||||
const sorted = [...nodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0));
|
||||
|
||||
// Find max edge count for scaling
|
||||
const maxEdges = Math.max(...sorted.map(n => n.edge_count ?? 0), 1);
|
||||
|
||||
// Position nodes using a spiral/cluster approach
|
||||
// High-edge nodes get placed more centrally with more space
|
||||
return sorted.map((node, index) => {
|
||||
const edgeCount = node.edge_count ?? 0;
|
||||
const edgeRatio = edgeCount / maxEdges;
|
||||
|
||||
// Radius from center - higher edge count = closer to center
|
||||
// Use golden angle for nice distribution
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
const angle = index * goldenAngle;
|
||||
|
||||
// Distance from center inversely proportional to edge count
|
||||
// Top nodes cluster in center, others spread out
|
||||
// Extra spacing for labeled nodes (top 15) to prevent label overlap
|
||||
const isLabeled = index < LABEL_THRESHOLD;
|
||||
const labelSpacing = isLabeled ? 60 : 0;
|
||||
const baseDistance = 80 + labelSpacing + (1 - edgeRatio) * Math.min(width, height) * 0.35;
|
||||
const distance = baseDistance + (index * 4); // More spread between nodes
|
||||
|
||||
const x = centerX + Math.cos(angle) * distance;
|
||||
const y = centerY + Math.sin(angle) * distance;
|
||||
|
||||
// Node size based on edge count
|
||||
// Min 3px for tiny dots, max ~20px for top nodes
|
||||
const minRadius = 3;
|
||||
const maxRadius = 18;
|
||||
const radius = minRadius + edgeRatio * (maxRadius - minRadius);
|
||||
|
||||
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,
|
||||
radius,
|
||||
};
|
||||
});
|
||||
}, [nodes, containerSize]);
|
||||
|
||||
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]);
|
||||
|
||||
// Get edges between visible nodes
|
||||
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
|
||||
return 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 };
|
||||
return { id: edge.id, source, target };
|
||||
})
|
||||
.filter(Boolean) as Array<{ id: number; source: GraphNode; target: GraphNode; weight: number }>;
|
||||
|
||||
return weightedEdges
|
||||
.sort((a, b) => b.weight - a.weight)
|
||||
.slice(0, 800);
|
||||
.filter(Boolean) as Array<{ id: number; source: GraphNode; target: GraphNode }>;
|
||||
}, [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),
|
||||
}));
|
||||
};
|
||||
// Get connected node IDs for selected node
|
||||
const connectedNodeIds = useMemo(() => {
|
||||
if (!selectedNode) return new Set<number>();
|
||||
const connected = new Set<number>();
|
||||
edges.forEach(edge => {
|
||||
if (edge.from_node_id === selectedNode.id) connected.add(edge.to_node_id);
|
||||
if (edge.to_node_id === selectedNode.id) connected.add(edge.from_node_id);
|
||||
});
|
||||
return connected;
|
||||
}, [selectedNode, edges]);
|
||||
|
||||
// Pan handling
|
||||
const handlePanStart = (event: React.PointerEvent<SVGRectElement>) => {
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
@@ -208,139 +192,96 @@ export default function MapViewer() {
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
};
|
||||
|
||||
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, 3)
|
||||
: Math.max(prev.scale - 0.2, 0.5),
|
||||
}));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
|
||||
Generating map...
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' }}>
|
||||
Loading map...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
|
||||
Error: {error}
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ef4444' }}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (graphNodes.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>
|
||||
Not enough nodes to render a map yet
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' }}>
|
||||
No nodes to display
|
||||
</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 ref={containerRef} style={{ position: 'relative', height: '100%', background: '#080808' }}>
|
||||
{/* Zoom controls */}
|
||||
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 8, zIndex: 10 }}>
|
||||
<button onClick={() => handleZoom('in')} style={controlBtn} title="Zoom in">+</button>
|
||||
<button onClick={() => handleZoom('out')} style={controlBtn} title="Zoom out">−</button>
|
||||
<button onClick={() => handleZoom('reset')} style={controlBtn} title="Reset">⟳</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 => (
|
||||
{/* Selected node info */}
|
||||
{selectedNode && (
|
||||
<div style={infoPanel}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: 8 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
{selectedNode.title || 'Untitled'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedNode(null)}
|
||||
style={{ background: 'none', border: 'none', color: '#666', cursor: 'pointer', fontSize: 16 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
||||
{connectedNodeIds.size} connected nodes · {selectedNode.edge_count ?? 0} total edges
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#22c55e', marginBottom: 8 }}>
|
||||
Click a highlighted node to explore
|
||||
</div>
|
||||
{selectedNode.dimensions && selectedNode.dimensions.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{selectedNode.dimensions.slice(0, 5).map(dim => (
|
||||
<span
|
||||
key={`${hoverNode.id}-${dimension}`}
|
||||
key={dim}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '999px',
|
||||
background: '#0f1a12',
|
||||
border: '1px solid #1f3425',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
background: lockedDimensions.has(dim) ? '#132018' : '#1a1a1a',
|
||||
color: lockedDimensions.has(dim) ? '#86efac' : '#888',
|
||||
}}
|
||||
>
|
||||
<Folder size={10} />
|
||||
{dimension}
|
||||
{dim}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SVG Graph */}
|
||||
<svg width="100%" height="100%" style={{ display: 'block' }}>
|
||||
<defs />
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
@@ -349,9 +290,11 @@ export default function MapViewer() {
|
||||
onPointerDown={handlePanStart}
|
||||
/>
|
||||
<g transform={`translate(${transform.x} ${transform.y}) scale(${transform.scale})`}>
|
||||
{/* Edges */}
|
||||
{graphEdges.map(edge => {
|
||||
const thickness = Math.min(3, 0.5 + edge.weight / 300);
|
||||
const opacity = Math.min(0.7, 0.2 + edge.weight / 800);
|
||||
const isConnected = selectedNode && (
|
||||
edge.source.id === selectedNode.id || edge.target.id === selectedNode.id
|
||||
);
|
||||
return (
|
||||
<line
|
||||
key={edge.id}
|
||||
@@ -359,97 +302,124 @@ export default function MapViewer() {
|
||||
y1={edge.source.y}
|
||||
x2={edge.target.x}
|
||||
y2={edge.target.y}
|
||||
stroke="#1f2933"
|
||||
strokeWidth={thickness}
|
||||
strokeOpacity={opacity}
|
||||
stroke={isConnected ? '#22c55e' : '#374151'}
|
||||
strokeWidth={isConnected ? 1.5 : 0.75}
|
||||
strokeOpacity={selectedNode ? (isConnected ? 0.9 : 0.15) : 0.6}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{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}
|
||||
|
||||
{/* Nodes */}
|
||||
{graphNodes.map((node, index) => {
|
||||
const isTop = index < LABEL_THRESHOLD;
|
||||
const isSelected = selectedNode?.id === node.id;
|
||||
const isConnectedToSelected = connectedNodeIds.has(node.id);
|
||||
const isDimmed = selectedNode && !isSelected && !isConnectedToSelected;
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
onClick={() => setSelectedNode(isSelected ? null : node)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
opacity={isDimmed ? 0.25 : 1}
|
||||
>
|
||||
{(node.title || 'Untitled').slice(0, 24)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* Highlight ring for connected nodes */}
|
||||
{isConnectedToSelected && !isSelected && (
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={node.radius + 4}
|
||||
fill="none"
|
||||
stroke="#22c55e"
|
||||
strokeWidth={2}
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
)}
|
||||
{/* Node circle */}
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={node.radius}
|
||||
fill={isTop ? '#22c55e' : '#334155'}
|
||||
fillOpacity={isTop ? 0.6 : 0.4}
|
||||
stroke={isSelected ? '#fff' : isTop ? '#166534' : '#1e293b'}
|
||||
strokeWidth={isSelected ? 2 : isTop ? 1.5 : 0.5}
|
||||
/>
|
||||
|
||||
{/* Label for top nodes */}
|
||||
{isTop && (
|
||||
<>
|
||||
{/* Title */}
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + node.radius + 14}
|
||||
textAnchor="middle"
|
||||
fill="#e5e7eb"
|
||||
fontSize={11}
|
||||
fontWeight={500}
|
||||
>
|
||||
{(node.title || 'Untitled').slice(0, 20)}
|
||||
{(node.title?.length ?? 0) > 20 ? '…' : ''}
|
||||
</text>
|
||||
|
||||
{/* Top dimensions (max 3) */}
|
||||
{node.dimensions && node.dimensions.length > 0 && (() => {
|
||||
const dims = node.dimensions.slice(0, 3).map(d => d.length > 10 ? d.slice(0, 9) + '…' : d).join(' · ');
|
||||
const labelWidth = dims.length * 5 + 16;
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
x={node.x - labelWidth / 2}
|
||||
y={node.y + node.radius + 18}
|
||||
width={labelWidth}
|
||||
height={16}
|
||||
rx={8}
|
||||
fill="#141414"
|
||||
stroke="#262626"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + node.radius + 29}
|
||||
textAnchor="middle"
|
||||
fill="#a1a1aa"
|
||||
fontSize={9}
|
||||
>
|
||||
{dims}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</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',
|
||||
const controlBtn: CSSProperties = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 6,
|
||||
border: '1px solid #262626',
|
||||
background: '#141414',
|
||||
color: '#888',
|
||||
fontSize: 16,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const infoPanel: CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
width: 260,
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
zIndex: 10,
|
||||
};
|
||||
|
||||
@@ -17,8 +17,8 @@ export default function SettingsCog({ onClick }: SettingsCogProps) {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
background: '#1a1a1a',
|
||||
border: 'none', /* Remove border for cleaner look */
|
||||
borderRadius: '50%', /* Make it circular like an avatar */
|
||||
border: '2px solid #22c55e',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, type CSSProperties } from 'react';
|
||||
|
||||
interface ToolGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export default function ToolsViewer() {
|
||||
@@ -16,92 +14,89 @@ export default function ToolsViewer() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTools = async () => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tools');
|
||||
const result = await response.json();
|
||||
const res = await fetch('/api/tools');
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
setGroups(result.data.groups);
|
||||
setGroupedTools(result.data.tools);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tools:', error);
|
||||
} catch (e) {
|
||||
console.error('Failed to load tools:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTools();
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: '#888' }}>
|
||||
Loading tools...
|
||||
</div>
|
||||
);
|
||||
return <div style={loadingStyle}>Loading...</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>
|
||||
<div style={containerStyle}>
|
||||
<p style={descStyle}>Available tools grouped by function.</p>
|
||||
|
||||
{Object.entries(groups).map(([groupId, group]) => {
|
||||
const tools = groupedTools[groupId] || [];
|
||||
if (tools.length === 0) return null;
|
||||
{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>
|
||||
return (
|
||||
<div key={groupId} style={{ marginBottom: 24 }}>
|
||||
<div style={groupHeaderStyle}>
|
||||
<span style={groupTitleStyle}>{group.name}</span>
|
||||
<span style={countStyle}>{tools.length}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{tools.map((tool) => (
|
||||
<div key={tool.name} style={toolStyle}>
|
||||
<div style={toolNameStyle}>{tool.name}</div>
|
||||
<div style={toolDescStyle}>{tool.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
|
||||
const loadingStyle: CSSProperties = { padding: 24, color: '#6b7280' };
|
||||
const descStyle: CSSProperties = { fontSize: 13, color: '#6b7280', marginBottom: 20 };
|
||||
|
||||
const groupHeaderStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 10,
|
||||
paddingBottom: 8,
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
};
|
||||
|
||||
const groupTitleStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: '#e5e7eb' };
|
||||
const countStyle: CSSProperties = { fontSize: 11, color: '#6b7280' };
|
||||
|
||||
const toolStyle: CSSProperties = {
|
||||
padding: '10px 14px',
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
borderRadius: 6,
|
||||
};
|
||||
|
||||
const toolNameStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
color: '#22c55e',
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
const toolDescStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: '#9ca3af',
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, type CSSProperties } from 'react';
|
||||
|
||||
interface WorkflowDefinition {
|
||||
id: number;
|
||||
@@ -20,149 +20,116 @@ export default function WorkflowsViewer() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadWorkflows = async () => {
|
||||
const load = 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);
|
||||
const res = await fetch('/api/workflows');
|
||||
const result = await res.json();
|
||||
if (result.success) setWorkflows(result.data);
|
||||
} catch (e) {
|
||||
console.error('Failed to load workflows:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadWorkflows();
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: '#888' }}>
|
||||
Loading workflows...
|
||||
</div>
|
||||
);
|
||||
return <div style={loadingStyle}>Loading...</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;
|
||||
<div style={containerStyle}>
|
||||
<p style={descStyle}>Available workflows. Click to view instructions.</p>
|
||||
|
||||
{workflows.length === 0 ? (
|
||||
<div style={emptyStyle}>No workflows defined.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{workflows.map((w) => {
|
||||
const expanded = expandedId === w.id;
|
||||
return (
|
||||
<div
|
||||
key={workflow.id}
|
||||
style={{
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div key={w.id} style={cardStyle}>
|
||||
<div
|
||||
onClick={() => setExpandedId(isExpanded ? null : workflow.id)}
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: isExpanded ? '1px solid #2a2a2a' : 'none',
|
||||
}}
|
||||
onClick={() => setExpandedId(expanded ? null : w.id)}
|
||||
style={cardHeaderStyle}
|
||||
>
|
||||
<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 style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={titleStyle}>{w.displayName}</span>
|
||||
<span style={keyStyle}>{w.key}</span>
|
||||
</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 style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
color: w.enabled ? '#22c55e' : '#6b7280',
|
||||
}}>
|
||||
{w.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
transform: expanded ? 'rotate(180deg)' : 'rotate(0)',
|
||||
transition: 'transform 0.15s',
|
||||
}}>
|
||||
▼
|
||||
</span>
|
||||
</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 style={descRowStyle}>{w.description}</div>
|
||||
{expanded && (
|
||||
<div style={expandedStyle}>
|
||||
<div style={instructionsStyle}>{w.instructions}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
|
||||
const loadingStyle: CSSProperties = { padding: 24, color: '#6b7280' };
|
||||
const descStyle: CSSProperties = { fontSize: 13, color: '#6b7280', marginBottom: 20 };
|
||||
const emptyStyle: CSSProperties = { fontSize: 13, color: '#6b7280', textAlign: 'center', padding: 32 };
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const cardHeaderStyle: CSSProperties = {
|
||||
padding: '14px 16px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
const titleStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: '#e5e7eb' };
|
||||
const keyStyle: CSSProperties = { fontSize: 11, fontFamily: 'monospace', color: '#6b7280' };
|
||||
const descRowStyle: CSSProperties = { fontSize: 12, color: '#9ca3af', padding: '0 16px 14px', lineHeight: 1.5 };
|
||||
|
||||
const expandedStyle: CSSProperties = {
|
||||
padding: 16,
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.04)',
|
||||
background: 'rgba(0, 0, 0, 0.2)',
|
||||
};
|
||||
|
||||
const instructionsStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
color: '#d1d5db',
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.6,
|
||||
padding: 12,
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
borderRadius: 6,
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user