chore: codebase audit — remove dead code, unused deps, clean configs

Phase 1: Delete 19 dead component/service/tool files (4,200+ lines)
Phase 2: Remove 13 unused npm packages (Radix UI, shadcn utilities, ytdl-core, etc.)
Phase 3: Database schema — drop agent_delegations, add performance indexes
Phase 4: Remove 12 dead types from database.ts/analytics.ts/helpers.ts
Phase 5: Strip shadcn/ui theme from tailwind config
Phase 6: Clean env vars, fix broken imports (LocalKeyGate, MapViewer)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-01-29 08:52:15 +11:00
co-authored by Claude Opus 4.5
parent 65d22315e1
commit 9b2db68751
33 changed files with 13 additions and 5683 deletions
-1
View File
@@ -10,7 +10,6 @@ OPENAI_API_KEY=your-openai-api-key-here
# Optional overrides specifically for ra-h orchestration/delegation
RAH_ORCHESTRATOR_ANTHROPIC_API_KEY=
RAH_DELEGATE_OPENAI_API_KEY=
RAH_WISE_RAH_OPENAI_API_KEY= # Optional: GPT-5 for wise ra-h (falls back to OPENAI_API_KEY)
# Voice TTS defaults
RAH_TTS_MODEL=gpt-4o-mini-tts
-63
View File
@@ -1,63 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { CostAnalytics } from '@/services/analytics/costs';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const period = searchParams.get('period') || 'week';
const agent = searchParams.get('agent');
const traceId = searchParams.get('trace_id');
const startDate = searchParams.get('start_date');
const endDate = searchParams.get('end_date');
if (traceId) {
const traceCosts = CostAnalytics.getCostsByTrace(traceId);
return NextResponse.json(traceCosts);
}
if (agent) {
const agentCosts = CostAnalytics.getCostsByAgent(
agent,
startDate || undefined,
endDate || undefined
);
return NextResponse.json(agentCosts);
}
const days = period === 'day' ? 1 : period === 'week' ? 7 : period === 'month' ? 30 : 7;
if (startDate && endDate) {
const dateRangeCosts = CostAnalytics.getCostsByDateRange(startDate, endDate);
return NextResponse.json(dateRangeCosts);
}
const now = new Date();
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
const periodCosts = CostAnalytics.getCostsByDateRange(
start.toISOString(),
now.toISOString()
);
const cacheStats = CostAnalytics.getCacheEffectiveness(
start.toISOString(),
now.toISOString()
);
const dailyBreakdown = CostAnalytics.getDailyBreakdown(days);
return NextResponse.json({
period: period,
summary: periodCosts,
cache: cacheStats,
daily: dailyBreakdown,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('❌ [Analytics] Error:', errorMessage);
return NextResponse.json(
{ error: errorMessage },
{ status: 500 }
);
}
}
-67
View File
@@ -1,67 +0,0 @@
import { NextResponse } from 'next/server';
import type { CacheStats } from '@/types/prompts';
declare global {
// eslint-disable-next-line no-var
var lastCacheStats: CacheStats | undefined;
}
export async function GET() {
try {
const stats = global.lastCacheStats;
if (!stats) {
return NextResponse.json({
error: 'No cache statistics available yet',
message: 'Send a message to ra-h to generate cache stats'
}, { status: 404 });
}
const hitRate = stats.cacheReadInputTokens > 0 ? 'HIT' : 'MISS';
const totalInputTokens = stats.inputTokens + stats.cacheCreationInputTokens + stats.cacheReadInputTokens;
// Cost calculation (Sonnet 4.5 pricing)
const baseCost = 3.0; // $3 per million input tokens
const writeCost = baseCost * 1.25; // $3.75 per million
const readCost = baseCost * 0.1; // $0.30 per million
const actualCost = (
(stats.inputTokens * baseCost) +
(stats.cacheCreationInputTokens * writeCost) +
(stats.cacheReadInputTokens * readCost)
) / 1_000_000;
const noCacheCost = (totalInputTokens * baseCost) / 1_000_000;
const costSavingsPercentage = noCacheCost > 0
? Math.round(((noCacheCost - actualCost) / noCacheCost) * 100)
: 0;
return NextResponse.json({
status: 'success',
timestamp: new Date().toISOString(),
lastRequest: {
hitRate,
tokens: {
cacheWrite: stats.cacheCreationInputTokens,
cacheRead: stats.cacheReadInputTokens,
regular: stats.inputTokens,
totalInput: totalInputTokens,
output: stats.outputTokens
},
savings: {
tokenPercentage: stats.savingsPercentage,
costPercentage: costSavingsPercentage,
actualCostUSD: actualCost.toFixed(6),
noCacheCostUSD: noCacheCost.toFixed(6),
savedUSD: (noCacheCost - actualCost).toFixed(6)
}
}
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ error: 'Failed to fetch cache stats', details: errorMessage },
{ status: 500 }
);
}
}
-28
View File
@@ -1,28 +0,0 @@
import { NextRequest } from 'next/server';
import { resultCache } from '@/services/tools/resultCache';
export async function GET(
request: NextRequest,
ctx: { params: Promise<{ id: string }> }
) {
try {
const { id } = await ctx.params; // Next.js 15: params is a Promise
const data = resultCache.get(id);
if (!data) {
return new Response(JSON.stringify({ success: false, error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify({ success: true, data }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
return new Response(JSON.stringify({ success: false, error: e?.message || 'Failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
+1 -6
View File
@@ -1,10 +1,5 @@
import ThreePanelLayout from '@/components/layout/ThreePanelLayout';
import { LocalKeyGate } from '@/components/auth/LocalKeyGate';
export default function Home() {
return (
<LocalKeyGate>
<ThreePanelLayout />
</LocalKeyGate>
);
return <ThreePanelLayout />;
}
-137
View File
@@ -253,47 +253,6 @@ const searchEmbeddingsOutputSchema = {
)
};
// rah_list_workflows schemas
const listWorkflowsOutputSchema = {
count: z.number(),
workflows: z.array(
z.object({
key: z.string(),
displayName: z.string(),
description: z.string().nullable(),
enabled: z.boolean()
})
)
};
// rah_get_workflow schemas
const getWorkflowInputSchema = {
workflowKey: z.string().describe('Workflow key to fetch (e.g., "integrate", "prep")')
};
const getWorkflowOutputSchema = {
success: z.boolean(),
workflow: z.object({
key: z.string(),
displayName: z.string(),
description: z.string().nullable(),
instructions: z.string(),
enabled: z.boolean()
}).nullable(),
message: z.string()
};
// rah_execute_workflow schemas
const executeWorkflowInputSchema = {
workflowKey: z.string().describe('Workflow key to execute (e.g., "integrate")'),
nodeId: z.number().int().positive().optional().describe('Target node ID for the workflow')
};
const executeWorkflowOutputSchema = {
success: z.boolean(),
message: z.string()
};
// rah_extract_url schemas
const extractUrlInputSchema = {
url: z.string().url().describe('URL of the webpage to extract content from')
@@ -763,102 +722,6 @@ mcpServer.registerTool(
}
);
mcpServer.registerTool(
'rah_list_workflows',
{
title: 'List RA-H workflows',
description: 'List all available workflows.',
inputSchema: {},
outputSchema: listWorkflowsOutputSchema
},
async () => {
const result = await callRaHApi('/api/workflows', { method: 'GET' });
const workflows = Array.isArray(result.data) ? result.data : [];
return {
content: [{ type: 'text', text: `Found ${workflows.length} workflow(s).` }],
structuredContent: {
count: workflows.length,
workflows: workflows.map(w => ({
key: w.key,
displayName: w.displayName,
description: w.description || null,
enabled: w.enabled
}))
}
};
}
);
mcpServer.registerTool(
'rah_get_workflow',
{
title: 'Get RA-H workflow',
description: 'Fetch a workflow definition by key (e.g., "integrate", "prep"). Returns instructions that can be followed.',
inputSchema: getWorkflowInputSchema,
outputSchema: getWorkflowOutputSchema
},
async ({ workflowKey }) => {
try {
const result = await callRaHApi(`/api/workflows/${encodeURIComponent(workflowKey)}`, {
method: 'GET'
});
const workflow = result.data;
return {
content: [{ type: 'text', text: `Workflow "${workflowKey}": ${workflow?.displayName || 'Unknown'}` }],
structuredContent: {
success: true,
workflow: workflow ? {
key: workflow.key,
displayName: workflow.displayName,
description: workflow.description || null,
instructions: workflow.instructions,
enabled: workflow.enabled
} : null,
message: `Fetched workflow "${workflowKey}"`
}
};
} catch (e) {
return {
content: [{ type: 'text', text: `Workflow "${workflowKey}" not found.` }],
structuredContent: {
success: false,
workflow: null,
message: `Workflow "${workflowKey}" not found`
}
};
}
}
);
mcpServer.registerTool(
'rah_execute_workflow',
{
title: 'Execute RA-H workflow',
description: 'Execute a predefined workflow (e.g., "integrate" for connection discovery).',
inputSchema: executeWorkflowInputSchema,
outputSchema: executeWorkflowOutputSchema
},
async ({ workflowKey, nodeId }) => {
const payload = { workflowKey };
if (nodeId) payload.nodeId = nodeId;
const result = await callRaHApi('/api/workflows/execute', {
method: 'POST',
body: JSON.stringify(payload)
});
return {
content: [{ type: 'text', text: `Workflow "${workflowKey}" initiated.` }],
structuredContent: {
success: true,
message: result.message || `Workflow "${workflowKey}" initiated.`
}
};
}
);
mcpServer.registerTool(
'rah_extract_url',
{
-137
View File
@@ -202,47 +202,6 @@ const searchEmbeddingsOutputSchema = {
)
};
// rah_list_workflows schemas
const listWorkflowsOutputSchema = {
count: z.number(),
workflows: z.array(
z.object({
key: z.string(),
displayName: z.string(),
description: z.string().nullable(),
enabled: z.boolean()
})
)
};
// rah_get_workflow schemas
const getWorkflowInputSchema = {
workflowKey: z.string().describe('Workflow key to fetch (e.g., "integrate", "prep")')
};
const getWorkflowOutputSchema = {
success: z.boolean(),
workflow: z.object({
key: z.string(),
displayName: z.string(),
description: z.string().nullable(),
instructions: z.string(),
enabled: z.boolean()
}).nullable(),
message: z.string()
};
// rah_execute_workflow schemas
const executeWorkflowInputSchema = {
workflowKey: z.string().describe('Workflow key to execute (e.g., "integrate")'),
nodeId: z.number().int().positive().optional().describe('Target node ID for the workflow')
};
const executeWorkflowOutputSchema = {
success: z.boolean(),
message: z.string()
};
// rah_extract_url schemas
const extractUrlInputSchema = {
url: z.string().url().describe('URL of the webpage to extract content from')
@@ -735,102 +694,6 @@ server.registerTool(
}
);
server.registerTool(
'rah_list_workflows',
{
title: 'List RA-H workflows',
description: 'List all available workflows.',
inputSchema: {},
outputSchema: listWorkflowsOutputSchema
},
async () => {
const result = await callRaHApi('/api/workflows', { method: 'GET' });
const workflows = Array.isArray(result.data) ? result.data : [];
return {
content: [{ type: 'text', text: `Found ${workflows.length} workflow(s).` }],
structuredContent: {
count: workflows.length,
workflows: workflows.map(w => ({
key: w.key,
displayName: w.displayName,
description: w.description || null,
enabled: w.enabled
}))
}
};
}
);
server.registerTool(
'rah_get_workflow',
{
title: 'Get RA-H workflow',
description: 'Fetch a workflow definition by key (e.g., "integrate", "prep"). Returns instructions that can be followed.',
inputSchema: getWorkflowInputSchema,
outputSchema: getWorkflowOutputSchema
},
async ({ workflowKey }) => {
try {
const result = await callRaHApi(`/api/workflows/${encodeURIComponent(workflowKey)}`, {
method: 'GET'
});
const workflow = result.data;
return {
content: [{ type: 'text', text: `Workflow "${workflowKey}": ${workflow?.displayName || 'Unknown'}` }],
structuredContent: {
success: true,
workflow: workflow ? {
key: workflow.key,
displayName: workflow.displayName,
description: workflow.description || null,
instructions: workflow.instructions,
enabled: workflow.enabled
} : null,
message: `Fetched workflow "${workflowKey}"`
}
};
} catch (e) {
return {
content: [{ type: 'text', text: `Workflow "${workflowKey}" not found.` }],
structuredContent: {
success: false,
workflow: null,
message: `Workflow "${workflowKey}" not found`
}
};
}
}
);
server.registerTool(
'rah_execute_workflow',
{
title: 'Execute RA-H workflow',
description: 'Execute a predefined workflow (e.g., "integrate" for connection discovery).',
inputSchema: executeWorkflowInputSchema,
outputSchema: executeWorkflowOutputSchema
},
async ({ workflowKey, nodeId }) => {
const payload = { workflowKey };
if (nodeId) payload.nodeId = nodeId;
const result = await callRaHApi('/api/workflows/execute', {
method: 'POST',
body: JSON.stringify(payload)
});
return {
content: [{ type: 'text', text: `Workflow "${workflowKey}" initiated.` }],
structuredContent: {
success: true,
message: result.message || `Workflow "${workflowKey}" initiated.`
}
};
}
);
server.registerTool(
'rah_extract_url',
{
+1 -1048
View File
File diff suppressed because it is too large Load Diff
-13
View File
@@ -26,19 +26,12 @@
"@ai-sdk/react": "^3.0.29",
"@langchain/core": "^1.0.1",
"@langchain/textsplitters": "^1.0.1",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@xyflow/react": "^12.10.0",
"ai": "^6.0.27",
"better-sqlite3": "^12.2.0",
"cheerio": "^1.1.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"next": "15.1.3",
"next-themes": "^0.4.3",
"openai": "^4.103.0",
"pdf-parse": "^1.1.1",
"pdfjs-dist": "^5.4.296",
@@ -46,12 +39,8 @@
"react-dom": "^19.0.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^2.5.2",
"uuid": "^11.1.0",
"youtube-captions-scraper": "^2.0.3",
"youtube-transcript": "^1.2.1",
"youtube-transcript-plus": "^1.1.1",
"ytdl-core": "^4.11.5",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -59,7 +48,6 @@
"@types/node": "22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "19.0.2",
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.20",
"esbuild": "^0.23.1",
"eslint": "^8.57.0",
@@ -67,7 +55,6 @@
"postcss": "^8.4.49",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.4",
"typescript": "^5.7.2",
"vitest": "^3.2.4"
+8 -14
View File
@@ -264,20 +264,8 @@ CREATE INDEX IF NOT EXISTS idx_chat_memory_thread ON chat_memory_state(thread_id
SQL
fi
echo "Ensuring agent_delegations table exists..."
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
CREATE TABLE IF NOT EXISTS agent_delegations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT UNIQUE NOT NULL,
task TEXT NOT NULL,
context TEXT,
expected_outcome TEXT,
status TEXT NOT NULL DEFAULT 'queued',
summary TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
SQL
echo "Dropping legacy agent_delegations table if present..."
"$SQLITE_BIN" "$DB_PATH" "DROP TABLE IF EXISTS agent_delegations;"
if ! has_table node_dimensions; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
@@ -573,6 +561,12 @@ if has_table helpers && has_col helpers fluid_context; then
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE helpers DROP COLUMN fluid_context;" || true
fi
echo "Ensuring performance indexes exist..."
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
CREATE INDEX IF NOT EXISTS idx_nodes_updated_at ON nodes(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_chats_created_at ON chats(created_at DESC);
SQL
echo "Running VACUUM and ANALYZE..."
"$SQLITE_BIN" "$DB_PATH" "VACUUM; ANALYZE;"
-225
View File
@@ -1,225 +0,0 @@
#!/usr/bin/env node
/**
* Test script for Python to TypeScript migration
* Tests extractors and embeddings using TypeScript modules
*/
const fs = require('fs');
const path = require('path');
const Module = require('module');
const ts = require('typescript');
const { getSubtitles } = require('youtube-captions-scraper');
function isNetworkError(error) {
if (!error) return false;
const message = typeof error === 'string' ? error : error.message || '';
const code = error.code || '';
return [
'fetch failed',
'ENOTFOUND',
'ECONNRESET',
'EAI_AGAIN',
'ECONNREFUSED',
'ETIMEDOUT'
].some(token => message.includes(token)) || ['ENOTFOUND', 'ECONNRESET', 'EAI_AGAIN', 'ECONNREFUSED', 'ETIMEDOUT'].includes(code);
}
function requireTs(modulePath) {
const resolved = path.resolve(__dirname, '..', modulePath);
const source = fs.readFileSync(resolved, 'utf8');
const transpiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
moduleResolution: ts.ModuleResolutionKind.Node16,
resolveJsonModule: true,
skipLibCheck: true
},
fileName: resolved
});
const tempModule = new Module(resolved, module);
tempModule.filename = resolved;
tempModule.paths = Module._nodeModulePaths(path.dirname(resolved));
tempModule._compile(transpiled.outputText, resolved);
return tempModule.exports;
}
async function testTranscriptLibrary() {
console.log('🔍 Testing youtube-captions-scraper library directly...');
const testUrls = [
'dQw4w9WgXcQ', // Rick Roll
'MnrJzXM7a6o', // TED Talk
'jNQXAC9IVRw' // Popular video
];
let sawNetworkError = false;
let sawEmptyTranscript = false;
for (const videoId of testUrls) {
console.log(`\nTesting video: ${videoId}`);
try {
const transcript = await getSubtitles({ videoID: videoId });
if (!transcript || transcript.length === 0) {
sawEmptyTranscript = true;
console.log('⚠️ Library returned 0 segments (likely geo-blocked). Trying next video...');
continue;
}
console.log(`✅ Success! Got ${transcript.length} segments`);
console.log('First segment:', transcript[0]);
console.log('Sample formatted:', `[${transcript[0].start.toFixed(1)}s] ${transcript[0].text}`);
return 'passed'; // Exit on first success
} catch (error) {
if (isNetworkError(error)) {
sawNetworkError = true;
console.log('⚠️ Network unavailable, skipping this video');
} else {
console.log('❌ Failed:', error.message);
}
}
}
if (sawNetworkError) {
console.log('\n⚠️ Network unavailable for transcript tests, marking as SKIPPED');
return 'skipped';
}
if (sawEmptyTranscript) {
console.log('\n⚠️ Library could not fetch transcripts for any sample video (likely YouTube changes). Marking as SKIPPED.');
return 'skipped';
}
console.log('\n❌ No transcripts could be extracted from any test video');
return 'failed';
}
console.log('🧪 Testing TypeScript Migration...\n');
async function testYouTubeExtractor() {
console.log('📺 Testing YouTube Extractor...');
try {
const { extractYouTube } = requireTs('src/services/typescript/extractors/youtube.ts');
const result = await extractYouTube('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
console.log('✅ YouTube extraction successful');
console.log(` Title: ${result.metadata.title}`);
console.log(` Channel: ${result.metadata.author_name}`);
console.log(` Transcript length: ${result.chunk.length} chars\n`);
return 'passed';
} catch (error) {
if (isNetworkError(error)) {
console.warn('⚠️ Network unavailable, skipping YouTube extractor test\n');
return 'skipped';
}
console.error('❌ YouTube extraction failed:', error.message, '\n');
return 'failed';
}
}
async function testWebsiteExtractor() {
console.log('🌐 Testing Website Extractor...');
try {
const { extractWebsite } = requireTs('src/services/typescript/extractors/website.ts');
const result = await extractWebsite('https://example.com');
console.log('✅ Website extraction successful');
console.log(` Title: ${result.metadata.title}`);
console.log(` Content length: ${result.chunk.length} chars\n`);
return 'passed';
} catch (error) {
if (isNetworkError(error)) {
console.warn('⚠️ Network unavailable, skipping website extractor test\n');
return 'skipped';
}
console.error('❌ Website extraction failed:', error.message, '\n');
return 'failed';
}
}
async function testPDFExtractor() {
console.log('📄 Testing PDF Extractor...');
try {
const { extractPaper } = requireTs('src/services/typescript/extractors/paper.ts');
// Using a sample PDF URL
const samplePdf = 'https://raw.githubusercontent.com/mozilla/pdf.js/master/web/compressed.tracemonkey-pldi-09.pdf';
const result = await extractPaper(samplePdf);
console.log('✅ PDF extraction successful');
console.log(` Pages: ${result.metadata.pages}`);
console.log(` Text length: ${result.metadata.text_length} chars\n`);
return 'passed';
} catch (error) {
if (isNetworkError(error)) {
console.warn('⚠️ Network unavailable, skipping PDF extractor test\n');
return 'skipped';
}
console.error('❌ PDF extraction failed:', error.message, '\n');
return 'failed';
}
}
async function testNodeEmbedding() {
console.log('🔮 Testing Node Embedding...');
try {
// This would require a real node ID and database connection
console.log('⚠️ Skipping node embedding test (requires database and API key)\n');
return 'skipped';
} catch (error) {
console.error('❌ Node embedding failed:', error.message, '\n');
return 'failed';
}
}
async function runTests() {
console.log('Environment:');
console.log(` Node version: ${process.version}\n`);
// First test the library directly
const libraryStatus = await testTranscriptLibrary();
const results = {
library: libraryStatus,
youtube: await testYouTubeExtractor(),
website: await testWebsiteExtractor(),
pdf: await testPDFExtractor(),
embedding: await testNodeEmbedding()
};
console.log('📊 Test Results:');
console.log('─────────────────');
let passed = 0;
let failed = 0;
let skipped = 0;
for (const [test, result] of Object.entries(results)) {
if (result === 'passed') {
console.log(` ${test}: ✅ PASSED`);
passed++;
} else if (result === 'skipped') {
console.log(` ${test}: ⚠️ SKIPPED`);
skipped++;
} else {
console.log(` ${test}: ❌ FAILED`);
failed++;
}
}
console.log('\n📈 Summary:');
console.log(` Passed: ${passed}`);
console.log(` Failed: ${failed}`);
console.log(` Skipped: ${skipped}`);
if (failed === 0) {
console.log('\n🎉 No hard failures. Re-run skipped tests when network/database access is available.');
} else {
console.log('\n⚠️ Some tests failed. Check the errors above.');
}
process.exit(failed > 0 ? 1 : 0);
}
// Run tests
runTests().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
});
-99
View File
@@ -1,99 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { isLocalMode } from '@/config/runtime';
import { apiKeyService } from '@/services/storage/apiKeys';
interface LocalKeyGateProps {
children: React.ReactNode;
}
const panelStyle: React.CSSProperties = {
maxWidth: 420,
background: 'rgba(15, 15, 15, 0.92)',
border: '1px solid #2a2a2a',
borderRadius: 16,
padding: '28px 32px',
boxShadow: '0 18px 40px rgba(0,0,0,0.45)'
};
const buttonStyle: React.CSSProperties = {
background: '#22c55e',
color: '#0b1113',
border: 'none',
borderRadius: 6,
padding: '12px 18px',
fontWeight: 600,
cursor: 'pointer'
};
export function LocalKeyGate({ children }: LocalKeyGateProps) {
const isLocal = useMemo(() => isLocalMode(), []);
const [hasKeys, setHasKeys] = useState(() => (!isLocal) || apiKeyService.hasUserKeys());
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!isLocal) return;
const handleUpdate = () => {
setHasKeys(apiKeyService.hasUserKeys());
};
handleUpdate();
const listener = () => handleUpdate();
window.addEventListener('api-keys:updated', listener);
return () => window.removeEventListener('api-keys:updated', listener);
}, [isLocal]);
if (!isLocal || hasKeys) {
return <>{children}</>;
}
const openApiKeySettings = () => {
window.dispatchEvent(new CustomEvent('settings:open', { detail: { tab: 'apikeys' } }));
};
const popupContent = (
<div
style={{
position: 'fixed',
top: 24,
right: 24,
maxWidth: 420,
zIndex: 99999,
pointerEvents: 'auto'
}}
>
<div style={panelStyle}>
<div style={{ marginBottom: 16 }}>
<h2 style={{ marginBottom: 8, fontSize: '20px' }}>Connect your AI keys</h2>
<p style={{ color: '#b0b8c3', lineHeight: 1.6 }}>
Local mode needs an OpenAI or Anthropic key. Add one under <strong>Settings API Keys</strong>
to unlock the workspace.
</p>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap' }}>
<button style={buttonStyle} onClick={openApiKeySettings}>
Open API Key Settings
</button>
<button
style={{ ...buttonStyle, background: '#1f2933', color: '#e5e7eb' }}
onClick={() => setHasKeys(true)}
>
Dismiss
</button>
</div>
</div>
</div>
);
return (
<>
{children}
{mounted && createPortal(popupContent, document.body)}
</>
);
}
-129
View File
@@ -1,129 +0,0 @@
"use client";
import { ReactNode, CSSProperties } from 'react';
interface EditableSectionProps {
label: string;
summary: ReactNode;
expanded: boolean;
onToggle: () => void;
children: ReactNode;
metadata?: string;
disabled?: boolean;
}
export default function EditableSection({
label,
summary,
expanded,
onToggle,
children,
metadata,
disabled = false
}: EditableSectionProps) {
return (
<div style={{
marginBottom: '12px',
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: '4px',
overflow: 'visible'
}}>
<div
onClick={disabled ? undefined : onToggle}
style={{
padding: '8px 12px',
cursor: disabled ? 'default' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'background 0.2s',
background: expanded ? '#202020' : 'transparent'
}}
onMouseEnter={(e) => {
if (!disabled && !expanded) {
e.currentTarget.style.background = '#1f1f1f';
}
}}
onMouseLeave={(e) => {
if (!disabled && !expanded) {
e.currentTarget.style.background = 'transparent';
}
}}
>
<span style={{
fontSize: '10px',
fontWeight: 600,
color: '#5c9aff',
textTransform: 'uppercase',
letterSpacing: '0.05em',
minWidth: '40px'
}}>
{label}
</span>
<div style={{
flex: 1,
fontSize: '11px',
color: '#888',
lineHeight: '1.4',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{summary}
</div>
{metadata && (
<span style={{
fontSize: '10px',
color: '#555',
flexShrink: 0
}}>
{metadata}
</span>
)}
{!disabled && (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
style={{
color: '#555',
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s'
}}
>
<polyline points="9 18 15 12 9 6" />
</svg>
)}
{disabled && (
<span style={{
fontSize: '9px',
color: '#555',
background: '#252525',
padding: '2px 4px',
borderRadius: '2px'
}}>
locked
</span>
)}
</div>
{expanded && (
<div style={{
padding: '12px',
borderTop: '1px solid #2a2a2a',
background: '#161616',
position: 'relative'
}}>
{children}
</div>
)}
</div>
);
}
-67
View File
@@ -1,67 +0,0 @@
"use client";
import { ReactNode, CSSProperties } from 'react';
interface PanelHeaderProps {
title: string;
leftContent?: ReactNode;
rightContent?: ReactNode;
className?: string;
style?: CSSProperties;
}
export default function PanelHeader({
title,
leftContent,
rightContent,
className = '',
style = {}
}: PanelHeaderProps) {
return (
<div
className={`panel-header ${className}`}
style={{
height: '48px',
padding: '0 12px',
borderBottom: '1px solid rgba(42, 42, 42, 0.8)',
background: '#0f0f0f',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
...style
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
flex: 1,
minWidth: 0
}}>
<span style={{
textTransform: 'uppercase',
fontSize: '12px',
fontWeight: 500,
color: '#888',
letterSpacing: '0.05em',
flexShrink: 0
}}>
{title}
</span>
{leftContent}
</div>
{rightContent && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
flexShrink: 0
}}>
{rightContent}
</div>
)}
</div>
);
}
@@ -1,400 +0,0 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
interface EdgeSearchModalProps {
isOpen: boolean;
onClose: () => void;
onEdgeCreate: (targetNodeId: number, targetNodeTitle: string) => void;
sourceNodeId: number;
}
interface NodeSuggestion {
id: number;
title: string;
dimensions?: string[];
}
export default function EdgeSearchModal({
isOpen,
onClose,
onEdgeCreate,
sourceNodeId
}: EdgeSearchModalProps) {
const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState<NodeSuggestion[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
// Store the element that triggered the modal for return focus
useEffect(() => {
if (isOpen && document.activeElement instanceof HTMLElement) {
returnFocusRef.current = document.activeElement;
}
}, [isOpen]);
// Focus trap and accessibility
useEffect(() => {
if (!isOpen) return;
// Autofocus input
inputRef.current?.focus();
// Lock body scroll
document.body.style.overflow = 'hidden';
// Handle Escape key
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
// Focus trap: keep focus within modal
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements || focusableElements.length === 0) return;
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleEscape);
document.addEventListener('keydown', handleTab);
return () => {
document.body.style.overflow = '';
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('keydown', handleTab);
// Return focus to trigger element
if (returnFocusRef.current) {
returnFocusRef.current.focus();
}
};
}, [isOpen, onClose]);
// Generate suggestions based on search query
useEffect(() => {
if (!searchQuery.trim()) {
setSuggestions([]);
return;
}
const fetchSuggestions = async () => {
try {
const response = await fetch(`/api/nodes/search?q=${encodeURIComponent(searchQuery)}&limit=10`);
const result = await response.json();
if (result.success) {
const nodeSuggestions: NodeSuggestion[] = result.data
.filter((node: any) => node.id !== sourceNodeId) // Exclude source node
.map((node: any) => ({
id: node.id,
title: node.title,
dimensions: node.dimensions || []
}));
setSuggestions(nodeSuggestions);
setSelectedIndex(0);
}
} catch (error) {
console.error('Error fetching suggestions:', error);
setSuggestions([]);
}
};
const timeoutId = setTimeout(fetchSuggestions, 200);
return () => clearTimeout(timeoutId);
}, [searchQuery, sourceNodeId]);
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter' && suggestions[selectedIndex]) {
e.preventDefault();
handleSelectSuggestion(suggestions[selectedIndex]);
}
};
const handleSelectSuggestion = (suggestion: NodeSuggestion) => {
onEdgeCreate(suggestion.id, suggestion.title);
setSearchQuery('');
setSuggestions([]);
onClose();
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
if (!isOpen) return null;
const modalContent = (
<div
className="search-backdrop"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-label="Search nodes to connect"
>
<div ref={modalRef} className="search-container">
{/* Search Input */}
<div className="search-input-wrapper">
<svg className="search-icon" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
</svg>
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Connect to node..."
className="search-input"
/>
<div className="search-shortcut">
<kbd>esc</kbd>
</div>
</div>
{/* Results */}
{suggestions.length > 0 && (
<div className="search-results">
{suggestions.map((suggestion, index) => (
<button
key={suggestion.id}
onClick={() => handleSelectSuggestion(suggestion)}
onMouseEnter={() => setSelectedIndex(index)}
className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`}
>
<span className="result-id">#{suggestion.id}</span>
<span className="result-title">{suggestion.title}</span>
{index === selectedIndex && (
<span className="result-hint"></span>
)}
</button>
))}
</div>
)}
{/* Empty state */}
{searchQuery && suggestions.length === 0 && (
<div className="search-empty">
No results for "{searchQuery}"
</div>
)}
</div>
<style jsx>{`
.search-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
display: flex;
justify-content: center;
padding-top: 15vh;
z-index: 9999;
animation: backdropIn 200ms ease-out;
}
.search-container {
width: 100%;
max-width: 640px;
max-height: 70vh;
animation: containerIn 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.search-input-wrapper {
display: flex;
align-items: center;
gap: 16px;
background: #141414;
border: 1px solid #262626;
border-radius: 16px;
padding: 20px 24px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04),
0 24px 48px -12px rgba(0, 0, 0, 0.6);
}
.search-icon {
width: 22px;
height: 22px;
color: #525252;
flex-shrink: 0;
}
.search-input {
flex: 1;
background: none;
border: none;
outline: none;
color: #fafafa;
font-size: 18px;
font-family: inherit;
font-weight: 400;
}
.search-input::placeholder {
color: #525252;
}
.search-shortcut {
display: flex;
align-items: center;
gap: 4px;
}
.search-shortcut kbd {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
background: #262626;
border-radius: 6px;
font-size: 11px;
font-family: inherit;
color: #737373;
border: 1px solid #333;
}
.search-results {
margin-top: 8px;
background: #141414;
border: 1px solid #262626;
border-radius: 16px;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04),
0 24px 48px -12px rgba(0, 0, 0, 0.6);
animation: resultsIn 150ms ease-out;
}
.search-result-item {
width: 100%;
display: flex;
align-items: center;
gap: 14px;
padding: 16px 20px;
background: transparent;
border: none;
border-bottom: 1px solid #1f1f1f;
cursor: pointer;
transition: background 100ms ease;
text-align: left;
font-family: inherit;
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover,
.search-result-item.selected {
background: #1a1a1a;
}
.result-id {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #0a0a0a;
background: #22c55e;
padding: 4px 8px;
border-radius: 6px;
min-width: 28px;
flex-shrink: 0;
}
.result-title {
flex: 1;
color: #e5e5e5;
font-size: 15px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-hint {
color: #525252;
font-size: 13px;
}
.search-empty {
margin-top: 8px;
padding: 32px 24px;
background: #141414;
border: 1px solid #262626;
border-radius: 16px;
color: #525252;
font-size: 14px;
text-align: center;
}
@keyframes backdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes containerIn {
from {
opacity: 0;
transform: scale(0.96) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes resultsIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
</div>
);
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
}
-111
View File
@@ -1,111 +0,0 @@
"use client";
import { ChevronLeft, ChevronRight, FileText, MessageSquare, Workflow, FolderOpen, Map, LayoutList } from 'lucide-react';
type RailPosition = 'left' | 'right';
type PaneType = 'nodes' | 'node' | 'chat' | 'workflows' | 'dimensions' | 'map' | 'views';
interface CollapsedRailProps {
position: RailPosition;
paneType: PaneType;
onExpand: () => void;
shortcut?: string;
}
const PANE_ICONS: Record<PaneType, React.ReactNode> = {
nodes: <FileText size={18} />,
node: <FileText size={18} />,
chat: <MessageSquare size={18} />,
workflows: <Workflow size={18} />,
dimensions: <FolderOpen size={18} />,
map: <Map size={18} />,
views: <LayoutList size={18} />,
};
const PANE_LABELS: Record<PaneType, string> = {
nodes: 'Nodes',
node: 'Focus',
chat: 'Chat',
workflows: 'Workflows',
dimensions: 'Dimensions',
map: 'Map',
views: 'Feed',
};
export default function CollapsedRail({ position, paneType, onExpand, shortcut }: CollapsedRailProps) {
const isLeft = position === 'left';
const ChevronIcon = isLeft ? ChevronRight : ChevronLeft;
return (
<div
onClick={onExpand}
style={{
width: '40px',
height: '100%',
flexShrink: 0,
borderLeft: isLeft ? 'none' : '1px solid #1f1f1f',
borderRight: isLeft ? '1px solid #1f1f1f' : 'none',
background: '#0c0c0c',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
paddingTop: '12px',
gap: '8px',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#141414';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#0c0c0c';
}}
title={`Expand ${PANE_LABELS[paneType]}${shortcut ? ` (${shortcut})` : ''}`}
>
{/* Icon representing the collapsed panel */}
<div
style={{
width: '32px',
height: '32px',
borderRadius: '6px',
background: '#1a1a1a',
border: '1px solid #333',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#888',
transition: 'all 0.15s ease',
}}
>
{PANE_ICONS[paneType]}
</div>
{/* Chevron indicator */}
<div
style={{
color: '#666',
marginTop: '4px',
}}
>
<ChevronIcon size={14} />
</div>
{/* Vertical label */}
<div
style={{
writingMode: 'vertical-rl',
textOrientation: 'mixed',
transform: isLeft ? 'rotate(180deg)' : 'none',
fontSize: '10px',
fontWeight: 500,
color: '#777',
letterSpacing: '0.05em',
textTransform: 'uppercase',
marginTop: '8px',
}}
>
{PANE_LABELS[paneType]}
</div>
</div>
);
}
-43
View File
@@ -1,43 +0,0 @@
"use client";
interface AddNodeButtonProps {
onAddNode: () => void;
loading?: boolean;
}
export default function AddNodeButton({ onAddNode, loading = false }: AddNodeButtonProps) {
return (
<button
onClick={onAddNode}
disabled={loading}
style={{
background: 'transparent',
border: 'none',
color: loading ? '#666' : '#fff',
fontSize: '16px',
padding: '6px',
cursor: loading ? 'not-allowed' : 'pointer',
borderRadius: '3px',
transition: 'all 0.1s',
fontFamily: 'inherit',
width: '28px',
height: '28px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onMouseEnter={(e) => {
if (!loading) {
e.currentTarget.style.background = '#1a1a1a';
}
}}
onMouseLeave={(e) => {
if (!loading) {
e.currentTarget.style.background = 'transparent';
}
}}
>
{loading ? '...' : '+'}
</button>
);
}
-892
View File
@@ -1,892 +0,0 @@
"use client";
import { useState, useEffect, useRef, type DragEvent } from 'react';
import { ChevronRight, ChevronDown, Folder, FolderOpen, Layers, List, Maximize2, Minimize2 } from 'lucide-react';
import { Node } from '@/types/database';
import AddNodeButton from './AddNodeButton';
import Chip from '../common/Chip';
import { getNodeIcon } from '@/utils/nodeIcons';
import SearchModal from './SearchModal';
import { DynamicIcon } from '../common/LucideIconPicker';
import { usePersistentState } from '@/hooks/usePersistentState';
interface NodesPanelProps {
selectedNodes: Set<number>;
onNodeSelect: (nodeId: number, multiSelect: boolean) => void;
onNodeCreated?: (node: Node) => void;
onNodeDeleted?: (nodeId: number) => void;
refreshTrigger?: number;
onToggleFolderView?: (isOpen?: boolean) => void;
folderViewOpen?: boolean;
}
interface LockedDimension {
dimension: string;
count: number;
isPriority: boolean;
}
export default function NodesPanel({ selectedNodes, onNodeSelect, onNodeCreated, onNodeDeleted, refreshTrigger, onToggleFolderView, folderViewOpen = false }: NodesPanelProps) {
const [nodes, setNodes] = useState<Node[]>([]);
const [allNodes, setAllNodes] = useState<Node[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [deletingNode, setDeletingNode] = useState<number | null>(null);
const [priorityDimensions, setPriorityDimensions] = useState<string[]>([]);
const [lockedDimensions, setLockedDimensions] = useState<LockedDimension[]>([]);
const [expandedDimensions, setExpandedDimensions] = useState<Set<string>>(() => {
if (typeof window === 'undefined') return new Set();
const saved = window.localStorage.getItem('expandedDimensions');
return saved ? new Set(JSON.parse(saved)) : new Set();
});
const [dimensionNodes, setDimensionNodes] = useState<Record<string, Node[]>>({});
const [selectedFilters, setSelectedFilters] = useState<{type: 'dimension' | 'title', value: string}[]>([]);
const [dimensionsSectionCollapsed, setDimensionsSectionCollapsed] = useState(true); // Default: collapsed
const [allNodesSectionCollapsed, setAllNodesSectionCollapsed] = useState(false);
const [showSearchModal, setShowSearchModal] = useState(false);
const [dropFeedback, setDropFeedback] = useState<string | null>(null);
const [dragHoverDimension, setDragHoverDimension] = useState<string | null>(null);
const draggedNodeRef = useRef<{ id: number; dimensions?: string[] } | null>(null);
// Dimension icons (shared with FolderViewOverlay via localStorage)
const [dimensionIcons] = usePersistentState<Record<string, string>>('ui.dimensionIcons', {});
useEffect(() => {
fetchNodes();
fetchLockedDimensions();
}, []);
// Save expanded dimensions to localStorage
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem('expandedDimensions', JSON.stringify([...expandedDimensions]));
}, [expandedDimensions]);
// Refresh nodes and locked dimensions when SSE events trigger updates
useEffect(() => {
if (refreshTrigger !== undefined && refreshTrigger > 0) {
console.log('🔄 Refreshing nodes and locked dimensions due to SSE event');
fetchNodes();
fetchLockedDimensions();
}
}, [refreshTrigger]);
// Global Cmd+K / Ctrl+K keyboard shortcut for search
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setShowSearchModal(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// Re-apply filters when allNodes changes
useEffect(() => {
if (selectedFilters.length > 0) {
applyFiltersWithSelection(selectedFilters);
} else {
setNodes(allNodes);
}
}, [allNodes, priorityDimensions]);
const fetchNodes = async () => {
try {
const response = await fetch('/api/nodes?limit=100');
const result = await response.json();
const fetchedNodes = result.data || [];
setAllNodes(fetchedNodes);
setNodes(fetchedNodes);
} catch (error) {
console.error('Error fetching nodes:', error);
} finally {
setLoading(false);
}
};
const fetchLockedDimensions = async () => {
try {
const response = await fetch('/api/dimensions/popular');
const result = await response.json();
if (result.success) {
const locked = result.data.filter((d: any) => d.isPriority);
setLockedDimensions(locked);
const priority = locked.map((d: any) => d.dimension);
setPriorityDimensions(priority);
}
} catch (error) {
console.error('Error fetching locked dimensions:', error);
}
};
const toggleDimension = async (dimension: string) => {
const isCurrentlyExpanded = expandedDimensions.has(dimension);
setExpandedDimensions(prev => {
const next = new Set(prev);
if (next.has(dimension)) {
next.delete(dimension);
} else {
next.add(dimension);
}
return next;
});
// Fetch nodes for this dimension when expanding (if not already fetched)
if (!isCurrentlyExpanded && !dimensionNodes[dimension]) {
await fetchNodesForDimension(dimension);
}
};
const fetchNodesForDimension = async (dimension: string) => {
try {
// Sort by edge count (most connected first), then updated_at
const response = await fetch(`/api/nodes?dimensions=${encodeURIComponent(dimension)}&limit=200&sortBy=edges`);
const result = await response.json();
if (result.success) {
setDimensionNodes(prev => ({
...prev,
[dimension]: result.data || []
}));
}
} catch (error) {
console.error(`Error fetching nodes for dimension ${dimension}:`, error);
}
};
const handleDimensionDragOver = (event: DragEvent<HTMLElement>) => {
if (event.dataTransfer.types.includes('application/node-info') || event.dataTransfer.types.includes('text/plain')) {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
}
};
const handleDimensionDragEnter = (event: DragEvent<HTMLElement>, dimension: string) => {
if (event.dataTransfer.types.includes('application/node-info') || event.dataTransfer.types.includes('text/plain')) {
setDragHoverDimension(dimension);
}
};
const handleDimensionDragLeave = (event: DragEvent<HTMLElement>, dimension: string) => {
if (dragHoverDimension === dimension) {
setDragHoverDimension(null);
}
};
const handleNodeDropOnDimension = async (event: DragEvent<HTMLElement>, dimension: string) => {
event.preventDefault();
event.stopPropagation();
// Try to get data from ref first (works in Electron/Tauri webviews)
let payload: { id: number; dimensions?: string[] } | null = draggedNodeRef.current;
// Fallback to dataTransfer for browser compatibility
if (!payload) {
const raw = event.dataTransfer.getData('application/node-info') || event.dataTransfer.getData('text/plain');
if (raw) {
try {
payload = JSON.parse(raw);
} catch (e) {
console.error('Failed to parse drag data:', e);
}
}
}
// Clear the ref
draggedNodeRef.current = null;
if (!payload?.id) {
console.warn('No valid node data in drop event');
return;
}
try {
const currentDimensions = payload.dimensions || [];
if (currentDimensions.some((dim) => dim.toLowerCase() === dimension.toLowerCase())) {
setDropFeedback(`Node already in ${dimension}`);
setTimeout(() => setDropFeedback(null), 2500);
return;
}
const updatedDimensions = Array.from(new Set([...currentDimensions, dimension]));
const response = await fetch(`/api/nodes/${payload.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dimensions: updatedDimensions })
});
if (!response.ok) {
throw new Error('Failed to update node dimensions');
}
setDropFeedback(`Added to ${dimension}`);
setTimeout(() => setDropFeedback(null), 2500);
if (expandedDimensions.has(dimension)) {
fetchNodesForDimension(dimension);
}
fetchLockedDimensions();
setDragHoverDimension(null);
} catch (error) {
console.error('Error handling node drop:', error);
setDropFeedback('Failed to add dimension');
setTimeout(() => setDropFeedback(null), 2500);
setDragHoverDimension(null);
}
};
const getNodesForDimension = (dimension: string): Node[] => {
// Use fetched dimension-specific nodes if available, otherwise filter from loaded nodes
return dimensionNodes[dimension] || nodes.filter(node =>
node.dimensions?.some(d => d.toLowerCase() === dimension.toLowerCase())
);
};
const applyFilters = () => {
if (selectedFilters.length === 0) {
setNodes(allNodes);
return;
}
const dimensionFilters = selectedFilters.filter(f => f.type === 'dimension').map(f => f.value);
const titleFilters = selectedFilters.filter(f => f.type === 'title').map(f => f.value);
const filtered = allNodes.filter(node => {
// Must match ALL selected dimension filters
const dimensionMatch = dimensionFilters.length === 0 ||
dimensionFilters.every(dim =>
node.dimensions?.some(nodeDim => nodeDim.toLowerCase() === dim.toLowerCase())
);
// Must match ANY selected title filter
const titleMatch = titleFilters.length === 0 ||
titleFilters.some(title =>
node.title?.toLowerCase().includes(title.toLowerCase())
);
return dimensionMatch && titleMatch;
});
// Sort by priority dimensions first
const sorted = filtered.sort((a, b) => {
const aHasPriority = a.dimensions?.some(dim => priorityDimensions.includes(dim)) || false;
const bHasPriority = b.dimensions?.some(dim => priorityDimensions.includes(dim)) || false;
if (aHasPriority && !bHasPriority) return -1;
if (!aHasPriority && bHasPriority) return 1;
return 0;
});
setNodes(sorted);
};
const addFilter = (type: 'dimension' | 'title', value: string) => {
const newFilter = { type, value };
const newFilters = [...selectedFilters, newFilter];
setSelectedFilters(newFilters);
// Apply filters immediately
setTimeout(() => {
if (newFilters.length === 0) {
setNodes(allNodes);
} else {
applyFiltersWithSelection(newFilters);
}
}, 0);
};
const removeFilter = (index: number) => {
const newFilters = selectedFilters.filter((_, i) => i !== index);
setSelectedFilters(newFilters);
// Apply filters immediately
setTimeout(() => {
if (newFilters.length === 0) {
setNodes(allNodes);
} else {
applyFiltersWithSelection(newFilters);
}
}, 0);
};
const applyFiltersWithSelection = async (filters: {type: 'dimension' | 'title', value: string}[]) => {
const dimensionFilters = filters.filter(f => f.type === 'dimension').map(f => f.value);
const titleFilters = filters.filter(f => f.type === 'title').map(f => f.value);
try {
// Build API query params
const params = new URLSearchParams();
if (dimensionFilters.length > 0) {
params.append('dimensions', dimensionFilters.join(','));
}
if (titleFilters.length > 0) {
// For title filters, we'll search each one and combine results
const titleResults: Node[] = [];
for (const title of titleFilters) {
const response = await fetch(`/api/nodes?search=${encodeURIComponent(title)}&limit=50`);
const result = await response.json();
if (result.success) {
titleResults.push(...result.data);
}
}
// If we have dimension filters too, intersect the results
if (dimensionFilters.length > 0) {
const dimensionResponse = await fetch(`/api/nodes?${params.toString()}&limit=200`);
const dimensionResult = await dimensionResponse.json();
if (dimensionResult.success) {
// Find intersection: nodes that match dimensions AND titles
const dimensionNodeIds = new Set(dimensionResult.data.map((n: Node) => n.id));
const intersectedNodes = titleResults.filter(node => dimensionNodeIds.has(node.id));
setNodes(intersectedNodes);
}
} else {
// Only title filters
setNodes(titleResults);
}
} else if (dimensionFilters.length > 0) {
// Only dimension filters
const response = await fetch(`/api/nodes?${params.toString()}&limit=200`);
const result = await response.json();
if (result.success) {
setNodes(result.data);
}
}
} catch (error) {
console.error('Error applying filters:', error);
// Fallback to local filtering
const filtered = allNodes.filter(node => {
const dimensionMatch = dimensionFilters.length === 0 ||
dimensionFilters.every(dim =>
node.dimensions?.some(nodeDim => nodeDim.toLowerCase() === dim.toLowerCase())
);
const titleMatch = titleFilters.length === 0 ||
titleFilters.some(title =>
node.title?.toLowerCase().includes(title.toLowerCase())
);
return dimensionMatch && titleMatch;
});
setNodes(filtered);
}
};
const clearSearch = () => {
setSelectedFilters([]);
setNodes(allNodes);
};
const collapseAllDimensions = () => {
setExpandedDimensions(new Set());
};
const handleNodeDragStart = (event: DragEvent<HTMLElement>, node: Node) => {
event.dataTransfer.effectAllowed = 'copyMove';
const nodeData = {
id: node.id,
title: node.title || 'Untitled',
dimensions: node.dimensions || []
};
// Store in ref for webview compatibility (dataTransfer.getData can fail in Electron/Tauri)
draggedNodeRef.current = nodeData;
// Set multiple MIME types for different drop targets
event.dataTransfer.setData('application/node-info', JSON.stringify(nodeData));
// For chat input drops - includes title for [NODE:id:"title"] token
event.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: node.id, title: node.title || 'Untitled' }));
// Fallback for browsers/webviews that only support text/plain
event.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${node.title || 'Untitled'}"]`);
const preview = document.createElement('div');
preview.textContent = node.title || `Node #${node.id}`;
preview.style.position = 'fixed';
preview.style.top = '-1000px';
preview.style.left = '-1000px';
preview.style.padding = '4px 8px';
preview.style.background = '#0f0f0f';
preview.style.color = '#f8fafc';
preview.style.fontSize = '11px';
preview.style.fontWeight = '600';
preview.style.borderRadius = '6px';
preview.style.border = '1px solid #1f1f1f';
document.body.appendChild(preview);
event.dataTransfer.setDragImage(preview, 6, 6);
setTimeout(() => {
if (preview.parentNode) {
preview.parentNode.removeChild(preview);
}
}, 0);
};
const handleNodeDragEnd = () => {
// Clear ref if drag ends without a drop
draggedNodeRef.current = null;
};
const renderNodeItem = (node: Node) => (
<button
key={node.id}
draggable
onDragStart={(event) => handleNodeDragStart(event, node)}
onDragEnd={handleNodeDragEnd}
onClick={(e) => {
const multiSelect = e.metaKey || e.ctrlKey;
onNodeSelect(node.id, multiSelect);
}}
style={{
width: '100%',
padding: '8px 16px',
margin: '0',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '14px',
fontFamily: 'inherit',
background: selectedNodes.has(node.id) ? '#1a1a1a' : 'transparent',
color: selectedNodes.has(node.id) ? '#fff' : '#d1d5db',
border: 'none',
cursor: 'pointer',
transition: 'background 0.1s',
whiteSpace: 'nowrap',
overflow: 'hidden'
}}
onMouseEnter={(e) => {
if (!selectedNodes.has(node.id)) {
e.currentTarget.style.background = '#0a0a0a';
}
}}
onMouseLeave={(e) => {
if (!selectedNodes.has(node.id)) {
e.currentTarget.style.background = 'transparent';
}
}}
>
{/* Node ID Badge */}
<span style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: '#22c55e',
color: '#0a0a0a',
fontSize: '9px',
fontWeight: 600,
padding: '1px 5px',
borderRadius: '3px',
flexShrink: 0,
fontFamily: 'monospace',
lineHeight: 1,
height: '16px'
}}>
#{node.id}
</span>
{/* Title */}
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1
}}>
{node.title || 'Untitled'}
</span>
{/* Source Icon - Only show if node has a link */}
{node.link && (
<span style={{
display: 'flex',
alignItems: 'center',
flexShrink: 0,
marginLeft: '8px'
}}>
{getNodeIcon(node)}
</span>
)}
</button>
);
const handleAddNode = async () => {
setCreating(true);
try {
const response = await fetch('/api/nodes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: 'New Node',
content: '',
link: null,
dimensions: []
}),
});
const result = await response.json();
if (result.success) {
// Add new node to both lists immediately
setAllNodes(prevNodes => [result.data, ...prevNodes]);
setNodes(prevNodes => [result.data, ...prevNodes]);
// Notify parent component about new node creation
if (onNodeCreated) {
onNodeCreated(result.data);
}
} else {
console.error('Failed to create node:', result.message);
}
} catch (error) {
console.error('Error creating node:', error);
} finally {
setCreating(false);
}
};
// handleDeleteNode removed - deletion only available in focus panel
const handleNodeSelectFromSearch = (nodeId: number) => {
// Open node in focus panel without filtering the nodes panel
onNodeSelect(nodeId, false);
};
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: 'transparent' }}>
{/* Search Modal */}
<SearchModal
isOpen={showSearchModal}
onClose={() => setShowSearchModal(false)}
onNodeSelect={handleNodeSelectFromSearch}
existingFilters={selectedFilters}
/>
{/* Nodes List */}
<div style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
padding: '8px 0'
}}>
{loading ? (
<div style={{
padding: '16px',
color: '#666',
fontSize: '14px'
}}>
Loading nodes...
</div>
) : (
<div>
{/* + ADD NODE Button & folder expand */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '0 8px' }}>
<button
onClick={handleAddNode}
disabled={creating}
style={{
flex: 1,
padding: '12px 12px',
fontSize: '11px',
fontWeight: 600,
color: '#22c55e',
textTransform: 'uppercase',
letterSpacing: '0.1em',
borderBottom: '1px solid #1a1a1a',
background: 'transparent',
border: 'none',
cursor: creating ? 'not-allowed' : 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (!creating) {
e.currentTarget.style.background = '#151515';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#0a0a0a';
}}
>
<span style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '20px',
height: '20px',
borderRadius: '50%',
background: '#22c55e',
color: '#0a0a0a',
fontSize: '16px',
lineHeight: 1,
fontWeight: 300,
flexShrink: 0
}}>+</span>
{creating ? 'Adding...' : 'Add Node'}
</button>
<button
onClick={() => onToggleFolderView?.()}
style={{
width: '28px',
height: '28px',
borderRadius: '6px',
border: '1px solid #1f1f1f',
background: 'transparent',
color: '#666',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
title={folderViewOpen ? 'Close dimension folder view' : 'Open dimension folder view'}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#1a1a1a';
e.currentTarget.style.color = '#999';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#666';
}}
>
{folderViewOpen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
</div>
{dropFeedback && (
<div style={{
margin: '6px 8px 0',
padding: '6px 10px',
borderRadius: '6px',
background: '#0d1a12',
color: '#7de8a5',
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
{dropFeedback}
</div>
)}
{/* SEARCH Button */}
<button
onClick={() => setShowSearchModal(true)}
style={{
width: '100%',
padding: '12px 16px',
margin: '4px 8px', /* Added side margins */
fontSize: '11px',
fontWeight: 600,
color: '#555',
textTransform: 'uppercase',
letterSpacing: '0.1em',
borderRadius: '4px', /* Added border radius */
background: 'transparent', /* Transparent to show panel color */
border: 'none',
cursor: 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#151515';
e.currentTarget.style.color = '#777';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#0a0a0a';
e.currentTarget.style.color = '#555';
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
</svg>
Search
</div>
<span style={{ fontSize: '9px', color: '#444', fontWeight: 400 }}>K</span>
</button>
{/* DIMENSIONS Section Header */}
{lockedDimensions.length > 0 && (
<button
onClick={() => setDimensionsSectionCollapsed(!dimensionsSectionCollapsed)}
style={{
width: '100%',
padding: '14px 18px',
fontSize: '14px',
fontWeight: 600,
color: dimensionsSectionCollapsed ? '#94a3b8' : '#f8fafc',
letterSpacing: '0.01em',
borderBottom: '1px solid #151515',
borderLeft: '3px solid transparent',
background: dimensionsSectionCollapsed ? '#050505' : '#0e1811',
borderTop: '1px solid #000',
borderRight: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'all 0.2s'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<Layers size={14} color={dimensionsSectionCollapsed ? '#94a3b8' : '#f8fafc'} />
<span>Dimensions</span>
</div>
{dimensionsSectionCollapsed ? (
<ChevronRight size={14} strokeWidth={2.5} color="#94a3b8" />
) : (
<ChevronDown size={14} strokeWidth={2.5} color="#f8fafc" />
)}
</button>
)}
{/* Locked Dimension Sections */}
{!dimensionsSectionCollapsed && lockedDimensions.map((lockedDim) => {
const dimNodes = getNodesForDimension(lockedDim.dimension);
const isExpanded = expandedDimensions.has(lockedDim.dimension);
return (
<div key={lockedDim.dimension} style={{ marginBottom: '4px' }}>
{/* Dimension Header */}
<button
onClick={() => toggleDimension(lockedDim.dimension)}
onDragOver={(e) => handleDimensionDragOver(e)}
onDragEnter={(e) => handleDimensionDragEnter(e, lockedDim.dimension)}
onDragLeave={(e) => handleDimensionDragLeave(e, lockedDim.dimension)}
onDrop={(e) => handleNodeDropOnDimension(e, lockedDim.dimension)}
style={{
width: '100%',
padding: '10px 18px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: dragHoverDimension === lockedDim.dimension ? '#152214' : (isExpanded ? '#0d0d0d' : '#050505'),
border: dragHoverDimension === lockedDim.dimension ? '1px solid #1f3d28' : 'none',
borderBottom: dragHoverDimension === lockedDim.dimension ? '1px solid #1f3d28' : '1px solid #121212',
borderLeft: '2px solid transparent',
color: '#cbd5f5',
fontSize: '12px',
fontWeight: 600,
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.2s'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', flex: 1 }}>
{dimensionIcons[lockedDim.dimension] ? (
<DynamicIcon
name={dimensionIcons[lockedDim.dimension]}
size={16}
style={{ color: isExpanded ? '#7de8a5' : '#64748b' }}
/>
) : isExpanded ? (
<FolderOpen size={16} color="#7de8a5" />
) : (
<Folder size={16} color="#64748b" />
)}
<span>
{lockedDim.dimension}
</span>
</div>
<span
style={{
minWidth: '32px',
textAlign: 'right',
fontSize: '11px',
color: '#94a3b8',
fontWeight: 500,
background: '#0f1a12',
borderRadius: '999px',
padding: '2px 8px'
}}
>
{lockedDim.count}
</span>
</button>
{/* Dimension Nodes (when expanded) */}
{isExpanded && (
<div>
{dimNodes.length === 0 ? (
<div style={{
padding: '12px 16px',
color: '#555',
fontSize: '12px',
fontStyle: 'italic'
}}>
No nodes in this dimension
</div>
) : (
dimNodes.map((node) => renderNodeItem(node))
)}
</div>
)}
</div>
);
})}
{/* ALL NODES Section Header */}
<button
onClick={() => setAllNodesSectionCollapsed(!allNodesSectionCollapsed)}
style={{
width: '100%',
padding: '14px 18px',
fontSize: '14px',
fontWeight: 600,
color: allNodesSectionCollapsed ? '#94a3b8' : '#f8fafc',
letterSpacing: '0.01em',
borderBottom: '1px solid #151515',
borderLeft: '3px solid transparent',
background: allNodesSectionCollapsed ? '#050505' : '#0e1811',
borderTop: '1px solid #000',
borderRight: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'all 0.2s'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<List size={14} color={allNodesSectionCollapsed ? '#94a3b8' : '#f8fafc'} />
<span>Nodes</span>
</div>
{allNodesSectionCollapsed ? (
<ChevronRight size={14} strokeWidth={2.5} color="#94a3b8" />
) : (
<ChevronDown size={14} strokeWidth={2.5} color="#f8fafc" />
)}
</button>
{/* All Nodes List */}
{!allNodesSectionCollapsed && (
nodes.length === 0 ? (
<div style={{
padding: '16px',
color: '#666',
fontSize: '14px',
textAlign: 'center'
}}>
{selectedFilters.length > 0 ? 'No nodes match filters' : 'No nodes yet - click "Add Node" above to get started'}
</div>
) : (
nodes.map((node) => renderNodeItem(node))
)
)}
</div>
)}
</div>
</div>
);
}
-425
View File
@@ -1,425 +0,0 @@
"use client";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import type { Edge, Node } from '@/types/database';
interface GraphNode extends Node {
edge_count?: number;
x: number;
y: number;
radius: number;
}
interface LockedDimension {
name: string;
}
const NODE_LIMIT = 200;
const LABEL_THRESHOLD = 15; // Top N nodes get labels
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 [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];
if (entry?.contentRect) {
setContainerSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, []);
// Fetch data
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const [nodesRes, edgesRes, dimsRes] = await Promise.all([
fetch(`/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`),
fetch('/api/edges'),
fetch('/api/dimensions'),
]);
if (!nodesRes.ok || !edgesRes.ok) {
throw new Error('Failed to load data');
}
const nodesPayload = await nodesRes.json();
const edgesPayload = await edgesRes.json();
setNodes(nodesPayload.data || []);
setEdges(edgesPayload.data || []);
// 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) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
fetchData();
}, []);
// Position nodes in a cluster layout
const graphNodes = useMemo<GraphNode[]>(() => {
if (nodes.length === 0) return [];
const { width, height } = containerSize;
const centerX = width / 2;
const centerY = height / 2;
// 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);
return {
...node,
x,
y,
radius,
};
});
}, [nodes, 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));
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;
return { id: edge.id, source, target };
})
.filter(Boolean) as Array<{ id: number; source: GraphNode; target: GraphNode }>;
}, [edges, graphNodes]);
// 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;
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);
};
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={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' }}>
Loading map...
</div>
);
}
if (error) {
return (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ef4444' }}>
{error}
</div>
);
}
if (graphNodes.length === 0) {
return (
<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: '#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>
{/* 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={dim}
style={{
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
background: lockedDimensions.has(dim) ? '#132018' : '#1a1a1a',
color: lockedDimensions.has(dim) ? '#86efac' : '#888',
}}
>
{dim}
</span>
))}
</div>
)}
</div>
)}
{/* SVG Graph */}
<svg width="100%" height="100%" style={{ display: 'block' }}>
<defs />
<rect
width="100%"
height="100%"
fill="transparent"
style={{ cursor: 'grab' }}
onPointerDown={handlePanStart}
/>
<g transform={`translate(${transform.x} ${transform.y}) scale(${transform.scale})`}>
{/* Edges */}
{graphEdges.map(edge => {
const isConnected = selectedNode && (
edge.source.id === selectedNode.id || edge.target.id === selectedNode.id
);
return (
<line
key={edge.id}
x1={edge.source.x}
y1={edge.source.y}
x2={edge.target.x}
y2={edge.target.y}
stroke={isConnected ? '#22c55e' : '#374151'}
strokeWidth={isConnected ? 1.5 : 0.75}
strokeOpacity={selectedNode ? (isConnected ? 0.9 : 0.15) : 0.6}
/>
);
})}
{/* 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}
>
{/* 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>
);
}
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,
};
-44
View File
@@ -1,44 +0,0 @@
"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: '2px solid #22c55e',
borderRadius: '50%',
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>
);
}
-18
View File
@@ -7,7 +7,6 @@ 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';
@@ -18,7 +17,6 @@ export type SettingsTab =
| 'workflows'
| 'apikeys'
| 'database'
| 'map'
| 'context'
| 'agents';
@@ -197,20 +195,6 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
>
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={{
@@ -315,7 +299,6 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
{activeTab === 'apikeys' && 'API Keys'}
{activeTab === 'database' && 'Knowledge Database'}
{activeTab === 'context' && 'Auto-Context'}
{activeTab === 'map' && 'Knowledge Map'}
{activeTab === 'agents' && 'External Agents'}
</h2>
<button
@@ -350,7 +333,6 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
{activeTab === 'apikeys' && <ApiKeysViewer />}
{activeTab === 'database' && <DatabaseViewer />}
{activeTab === 'context' && <ContextViewer />}
{activeTab === 'map' && <MapViewer />}
{activeTab === 'agents' && <ExternalAgentsPanel />}
</div>
</div>
-286
View File
@@ -1,286 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from 'react';
import { Eye, List, Columns, LayoutGrid } from 'lucide-react';
import { ViewType, ViewConfig, ViewFilter, DEFAULT_VIEW_CONFIG, KanbanColumn } from '@/types/views';
import { Node } from '@/types/database';
import ListView from './ListView';
import KanbanView from './KanbanView';
import GridView from './GridView';
import ViewFilters from './ViewFilters';
interface ViewPanelProps {
viewMode: ViewType;
onViewModeChange: (mode: ViewType) => void;
onNodeClick: (nodeId: number) => void;
refreshTrigger?: number;
}
export default function ViewPanel({
viewMode,
onViewModeChange,
onNodeClick,
refreshTrigger
}: ViewPanelProps) {
const [nodes, setNodes] = useState<Node[]>([]);
const [loading, setLoading] = useState(true);
const [config, setConfig] = useState<ViewConfig>(DEFAULT_VIEW_CONFIG);
const [dimensions, setDimensions] = useState<string[]>([]);
// Fetch all nodes
const fetchNodes = useCallback(async () => {
setLoading(true);
try {
// Build query params from filters
const params = new URLSearchParams();
params.set('limit', '500');
if (config.filters.length > 0) {
const includeDimensions = config.filters
.filter(f => f.operator === 'includes')
.map(f => f.dimension);
if (includeDimensions.length > 0) {
params.set('dimensions', includeDimensions.join(','));
}
}
if (config.sort) {
params.set('sortBy', config.sort.field);
params.set('sortOrder', config.sort.direction);
}
const response = await fetch(`/api/nodes?${params.toString()}`);
if (response.ok) {
const data = await response.json();
let filteredNodes = data.nodes || [];
// Apply exclude filters client-side
const excludeDimensions = config.filters
.filter(f => f.operator === 'excludes')
.map(f => f.dimension);
if (excludeDimensions.length > 0) {
filteredNodes = filteredNodes.filter((node: Node) =>
!excludeDimensions.some(dim => node.dimensions?.includes(dim))
);
}
setNodes(filteredNodes);
}
} catch (error) {
console.error('Failed to fetch nodes:', error);
} finally {
setLoading(false);
}
}, [config.filters, config.sort]);
// Fetch available dimensions
const fetchDimensions = useCallback(async () => {
try {
const response = await fetch('/api/dimensions/popular');
if (response.ok) {
const data = await response.json();
const dims = data.data?.map((d: { dimension: string }) => d.dimension) || [];
setDimensions(dims);
}
} catch (error) {
console.error('Failed to fetch dimensions:', error);
}
}, []);
useEffect(() => {
fetchNodes();
}, [fetchNodes, refreshTrigger]);
useEffect(() => {
fetchDimensions();
}, [fetchDimensions]);
const handleFilterChange = (filters: ViewFilter[]) => {
setConfig(prev => ({ ...prev, filters }));
};
const handleFilterLogicChange = (logic: 'and' | 'or') => {
setConfig(prev => ({ ...prev, filterLogic: logic }));
};
const handleColumnChange = (columns: KanbanColumn[]) => {
setConfig(prev => ({ ...prev, columns }));
};
const handleNodeDimensionUpdate = async (nodeId: number, newDimension: string, oldDimension?: string) => {
// Update node's dimensions (add new, remove old if kanban move)
try {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
let newDimensions = [...(node.dimensions || [])];
// Remove old dimension if moving in kanban
if (oldDimension) {
newDimensions = newDimensions.filter(d => d !== oldDimension);
}
// Add new dimension if not already present
if (!newDimensions.includes(newDimension)) {
newDimensions.push(newDimension);
}
const response = await fetch(`/api/nodes/${nodeId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dimensions: newDimensions })
});
if (response.ok) {
// Refresh nodes to get updated data
fetchNodes();
}
} catch (error) {
console.error('Failed to update node dimensions:', error);
}
};
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}>
{/* Header with View Mode Selector */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
borderBottom: '1px solid #333',
background: '#0a0a0a',
flexShrink: 0
}}>
<div style={{ fontSize: '12px', color: '#888', fontWeight: 500 }}>
{nodes.length} nodes
</div>
{/* View Mode Buttons */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '2px'
}}>
<button
onClick={() => onViewModeChange('focus')}
style={{
padding: '6px 8px',
background: viewMode === 'focus' ? '#1a1a1a' : 'transparent',
border: 'none',
borderRadius: '4px',
color: viewMode === 'focus' ? '#22c55e' : '#666',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
transition: 'all 0.2s'
}}
title="Focus View"
>
<Eye size={14} />
</button>
<button
onClick={() => onViewModeChange('list')}
style={{
padding: '6px 8px',
background: viewMode === 'list' ? '#1a1a1a' : 'transparent',
border: 'none',
borderRadius: '4px',
color: viewMode === 'list' ? '#22c55e' : '#666',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
transition: 'all 0.2s'
}}
title="List View"
>
<List size={14} />
</button>
<button
onClick={() => onViewModeChange('kanban')}
style={{
padding: '6px 8px',
background: viewMode === 'kanban' ? '#1a1a1a' : 'transparent',
border: 'none',
borderRadius: '4px',
color: viewMode === 'kanban' ? '#22c55e' : '#666',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
transition: 'all 0.2s'
}}
title="Kanban View"
>
<Columns size={14} />
</button>
<button
onClick={() => onViewModeChange('grid')}
style={{
padding: '6px 8px',
background: viewMode === 'grid' ? '#1a1a1a' : 'transparent',
border: 'none',
borderRadius: '4px',
color: viewMode === 'grid' ? '#22c55e' : '#666',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
transition: 'all 0.2s'
}}
title="Grid View"
>
<LayoutGrid size={14} />
</button>
</div>
</div>
{/* Filters Bar */}
<ViewFilters
filters={config.filters}
filterLogic={config.filterLogic}
dimensions={dimensions}
onFilterChange={handleFilterChange}
onFilterLogicChange={handleFilterLogicChange}
/>
{/* Content Area */}
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{loading ? (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#666'
}}>
Loading...
</div>
) : (
<>
{viewMode === 'list' && (
<ListView
nodes={nodes}
onNodeClick={onNodeClick}
/>
)}
{viewMode === 'kanban' && (
<KanbanView
nodes={nodes}
columns={config.columns || []}
dimensions={dimensions}
onNodeClick={onNodeClick}
onColumnChange={handleColumnChange}
onNodeDimensionUpdate={handleNodeDimensionUpdate}
/>
)}
{viewMode === 'grid' && (
<GridView
nodes={nodes}
onNodeClick={onNodeClick}
/>
)}
</>
)}
</div>
</div>
);
}
-313
View File
@@ -1,313 +0,0 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { CostReport, TraceCostSummary, CacheEffectiveness } from '@/types/analytics';
export class CostAnalytics {
static getCostsByDateRange(startDate: string, endDate: string): CostReport {
const db = getSQLiteClient();
const chats = db.prepare(`
SELECT
id,
helper_name,
metadata,
created_at
FROM chats
WHERE created_at >= ? AND created_at <= ?
`).all(startDate, endDate) as Array<{
id: number;
helper_name: string;
metadata: string;
created_at: string;
}>;
let totalCostUsd = 0;
let totalTokens = 0;
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheWriteTokens = 0;
let cacheHitCount = 0;
let cacheSavingsUsd = 0;
const costByAgent: Record<string, { costUsd: number; chats: number; tokens: number }> = {};
const costByModel: Record<string, { costUsd: number; chats: number; tokens: number }> = {};
for (const chat of chats) {
let metadata: any = {};
try {
metadata = JSON.parse(chat.metadata || '{}');
} catch (e) {
continue;
}
const costUsd = metadata.estimated_cost_usd || 0;
const tokens = metadata.total_tokens || 0;
const inputToks = metadata.input_tokens || 0;
const outputToks = metadata.output_tokens || 0;
const cacheRead = metadata.cache_read_tokens || 0;
const cacheWrite = metadata.cache_write_tokens || 0;
const cacheHit = metadata.cache_hit || false;
const model = metadata.model_used || 'unknown';
totalCostUsd += costUsd;
totalTokens += tokens;
inputTokens += inputToks;
outputTokens += outputToks;
cacheReadTokens += cacheRead;
cacheWriteTokens += cacheWrite;
if (cacheHit) cacheHitCount++;
if (metadata.cache_savings_pct && cacheHit) {
const fullCost = costUsd / (1 - (metadata.cache_savings_pct / 100));
cacheSavingsUsd += (fullCost - costUsd);
}
if (!costByAgent[chat.helper_name]) {
costByAgent[chat.helper_name] = { costUsd: 0, chats: 0, tokens: 0 };
}
costByAgent[chat.helper_name].costUsd += costUsd;
costByAgent[chat.helper_name].chats += 1;
costByAgent[chat.helper_name].tokens += tokens;
if (!costByModel[model]) {
costByModel[model] = { costUsd: 0, chats: 0, tokens: 0 };
}
costByModel[model].costUsd += costUsd;
costByModel[model].chats += 1;
costByModel[model].tokens += tokens;
}
const cacheRequests = chats.filter(c => {
try {
const meta = JSON.parse(c.metadata || '{}');
return meta.provider === 'anthropic';
} catch {
return false;
}
}).length;
return {
periodStart: startDate,
periodEnd: endDate,
totalCostUsd: parseFloat(totalCostUsd.toFixed(6)),
totalChats: chats.length,
totalTokens,
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
cacheHitRate: cacheRequests > 0 ? cacheHitCount / cacheRequests : 0,
cacheSavingsUsd: parseFloat(cacheSavingsUsd.toFixed(6)),
avgCostPerChat: chats.length > 0 ? parseFloat((totalCostUsd / chats.length).toFixed(6)) : 0,
avgTokensPerChat: chats.length > 0 ? Math.round(totalTokens / chats.length) : 0,
costByAgent,
costByModel,
};
}
static getCostsByAgent(agentName: string, startDate?: string, endDate?: string): CostReport {
const db = getSQLiteClient();
let query = `
SELECT
id,
helper_name,
metadata,
created_at
FROM chats
WHERE helper_name = ?
`;
const params: any[] = [agentName];
if (startDate && endDate) {
query += ` AND created_at >= ? AND created_at <= ?`;
params.push(startDate, endDate);
}
const chats = db.prepare(query).all(...params) as Array<{
id: number;
helper_name: string;
metadata: string;
created_at: string;
}>;
if (chats.length === 0) {
return {
periodStart: startDate || '',
periodEnd: endDate || '',
totalCostUsd: 0,
totalChats: 0,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheHitRate: 0,
cacheSavingsUsd: 0,
avgCostPerChat: 0,
avgTokensPerChat: 0,
costByAgent: {},
costByModel: {},
};
}
const start = startDate || chats[chats.length - 1].created_at;
const end = endDate || chats[0].created_at;
return this.getCostsByDateRange(start, end);
}
static getCostsByTrace(traceId: string): TraceCostSummary {
const db = getSQLiteClient();
const chats = db.prepare(`
SELECT
id,
helper_name,
agent_type,
metadata,
created_at
FROM chats
WHERE json_extract(metadata, '$.trace_id') = ?
ORDER BY created_at ASC
`).all(traceId) as Array<{
id: number;
helper_name: string;
agent_type: string;
metadata: string;
created_at: string;
}>;
let totalCostUsd = 0;
let orchestratorCost = 0;
let executorCost = 0;
let plannerCost = 0;
let totalTokens = 0;
const interactions: TraceCostSummary['interactions'] = [];
for (const chat of chats) {
let metadata: any = {};
try {
metadata = JSON.parse(chat.metadata || '{}');
} catch (e) {
continue;
}
const costUsd = metadata.estimated_cost_usd || 0;
const tokens = metadata.total_tokens || 0;
totalCostUsd += costUsd;
totalTokens += tokens;
if (chat.agent_type === 'orchestrator') {
orchestratorCost += costUsd;
} else if (chat.agent_type === 'executor') {
executorCost += costUsd;
} else if (chat.agent_type === 'planner') {
plannerCost += costUsd;
}
interactions.push({
chatId: chat.id,
agentName: chat.helper_name,
costUsd: parseFloat(costUsd.toFixed(6)),
tokens,
createdAt: chat.created_at,
});
}
return {
traceId,
totalCostUsd: parseFloat(totalCostUsd.toFixed(6)),
chatCount: chats.length,
orchestratorCost: parseFloat(orchestratorCost.toFixed(6)),
executorCost: parseFloat(executorCost.toFixed(6)),
plannerCost: parseFloat(plannerCost.toFixed(6)),
totalTokens,
interactions,
};
}
static getCacheEffectiveness(startDate?: string, endDate?: string): CacheEffectiveness {
const db = getSQLiteClient();
let query = `
SELECT
metadata
FROM chats
WHERE json_extract(metadata, '$.provider') = 'anthropic'
`;
const params: any[] = [];
if (startDate && endDate) {
query += ` AND created_at >= ? AND created_at <= ?`;
params.push(startDate, endDate);
}
const chats = db.prepare(query).all(...params) as Array<{ metadata: string }>;
let totalRequests = 0;
let cacheHits = 0;
let cacheMisses = 0;
let totalCacheSavingsUsd = 0;
let totalTokensSaved = 0;
for (const chat of chats) {
let metadata: any = {};
try {
metadata = JSON.parse(chat.metadata || '{}');
} catch (e) {
continue;
}
totalRequests++;
if (metadata.cache_hit) {
cacheHits++;
const cacheReadTokens = metadata.cache_read_tokens || 0;
totalTokensSaved += cacheReadTokens;
if (metadata.cache_savings_pct && metadata.estimated_cost_usd) {
const fullCost = metadata.estimated_cost_usd / (1 - (metadata.cache_savings_pct / 100));
totalCacheSavingsUsd += (fullCost - metadata.estimated_cost_usd);
}
} else {
cacheMisses++;
}
}
return {
totalRequests,
cacheHits,
cacheMisses,
hitRate: totalRequests > 0 ? parseFloat((cacheHits / totalRequests).toFixed(4)) : 0,
totalCacheSavingsUsd: parseFloat(totalCacheSavingsUsd.toFixed(6)),
avgSavingsPerHit: cacheHits > 0 ? parseFloat((totalCacheSavingsUsd / cacheHits).toFixed(6)) : 0,
totalTokensSaved,
};
}
static getDailyBreakdown(days: number = 7): Array<{ date: string; cost: number; chats: number; tokens: number }> {
const db = getSQLiteClient();
const result = db.prepare(`
SELECT
DATE(created_at) as date,
SUM(COALESCE(json_extract(metadata, '$.estimated_cost_usd'), 0)) as cost,
COUNT(*) as chats,
SUM(COALESCE(json_extract(metadata, '$.total_tokens'), 0)) as tokens
FROM chats
WHERE created_at >= DATE('now', '-' || ? || ' days')
GROUP BY DATE(created_at)
ORDER BY date DESC
`).all(days) as Array<{ date: string; cost: number; chats: number; tokens: number }>;
return result.map(row => ({
date: row.date,
cost: parseFloat(Number(row.cost).toFixed(6)),
chats: Number(row.chats),
tokens: Number(row.tokens),
}));
}
}
-32
View File
@@ -1,32 +0,0 @@
type Entry = { data: any; ts: number };
class ResultCache {
private store = new Map<string, Entry>();
private ttlMs = 10 * 60 * 1000; // 10 minutes
set(id: string, data: any) {
if (!id) return;
this.store.set(id, { data, ts: Date.now() });
this.gc();
}
get(id: string): any | null {
const e = this.store.get(id);
if (!e) return null;
if (Date.now() - e.ts > this.ttlMs) {
this.store.delete(id);
return null;
}
return e.data;
}
private gc() {
const now = Date.now();
for (const [k, v] of this.store.entries()) {
if (now - v.ts > this.ttlMs) this.store.delete(k);
}
}
}
export const resultCache = new ResultCache();
-398
View File
@@ -1,398 +0,0 @@
/* RA-H Terminal Theme Design System */
:root {
/* Core Backgrounds - Ultra minimal dark */
--bg-primary: #0a0a0a; /* Main app background */
--bg-secondary: #0f0f0f; /* Panel backgrounds */
--bg-surface: #151515; /* Elevated surfaces */
--bg-hover: #1a1a1a; /* Hover states */
/* Borders & Dividers - Subtle definition */
--border-subtle: #1f1f1f; /* Very subtle borders */
--border-default: #2a2a2a; /* Default borders */
--border-strong: #353535; /* Strong emphasis borders */
/* Text Hierarchy - High contrast */
--text-primary: #e5e5e5; /* Primary text */
--text-secondary: #a8a8a8; /* Secondary text */
--text-muted: #6b6b6b; /* Muted/disabled text */
--text-subtle: #4a4a4a; /* Very subtle text */
/* Terminal Accent Colors - Distinctive but minimal */
--accent-blue: #5c9aff; /* Primary actions / User messages */
--accent-green: #52d97a; /* Success / Assistant messages */
--accent-yellow: #ffcc66; /* Warning / Tool usage */
--accent-red: #ff6b6b; /* Error states */
--accent-purple: #b794f6; /* Thinking / Processing */
--accent-cyan: #69d2e7; /* Special highlights */
/* Message Status Indicators */
--dot-user: #5c9aff; /* User message indicator */
--dot-assistant: #52d97a; /* Assistant indicator */
--dot-tool: #ffcc66; /* Tool usage indicator */
--dot-thinking: #b794f6; /* Thinking indicator */
--dot-system: #69d2e7; /* System messages */
/* Semantic Colors */
--color-success: #52d97a;
--color-warning: #ffcc66;
--color-error: #ff6b6b;
--color-info: #5c9aff;
/* Typography */
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', ui-monospace, Consolas, monospace;
--font-size-xs: 11px;
--font-size-sm: 12px;
--font-size-base: 13px;
--font-size-md: 14px;
--font-size-lg: 16px;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 24px;
/* Animations */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Base Terminal Styles */
.terminal-container {
background: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--font-size-base);
line-height: 1.6;
letter-spacing: 0.025em;
font-feature-settings: 'liga' 1, 'calt' 1;
}
/* Message Display Styles */
.message-container {
display: flex;
gap: var(--spacing-md);
padding: var(--spacing-sm) 0;
font-size: var(--font-size-base);
line-height: 1.6;
}
.message-indicator {
flex-shrink: 0;
width: 6px;
height: 6px;
margin-top: 7px;
border-radius: 50%;
transition: all var(--transition-fast);
}
.message-indicator.user {
background: var(--dot-user);
}
.message-indicator.assistant {
background: var(--dot-assistant);
}
.message-indicator.tool {
background: var(--dot-tool);
}
.message-indicator.thinking {
background: var(--dot-thinking);
animation: pulse 1.5s infinite;
}
.message-indicator.system {
background: var(--dot-system);
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.2);
}
}
.message-content {
flex: 1;
min-width: 0;
}
.message-text {
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
.message-timestamp {
display: inline-block;
margin-top: var(--spacing-xs);
color: var(--text-subtle);
font-size: var(--font-size-xs);
}
/* Terminal Input Styles */
.terminal-input-container {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
padding: var(--spacing-md);
background: var(--bg-secondary);
border-top: 1px solid var(--border-default);
}
.prompt-symbol {
color: var(--text-muted);
font-size: var(--font-size-md);
line-height: 1.5;
padding-top: 2px;
user-select: none;
}
.terminal-input-wrapper {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.terminal-input {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--font-size-base);
padding: var(--spacing-sm) var(--spacing-md);
resize: none;
outline: none;
transition: all var(--transition-fast);
border-radius: 2px;
min-height: 32px;
line-height: 1.5;
}
.terminal-input:focus {
border-color: var(--accent-blue);
background: var(--bg-primary);
box-shadow: 0 0 0 1px rgba(92, 154, 255, 0.1);
}
.terminal-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.terminal-input::placeholder {
color: var(--text-subtle);
}
.input-actions {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.input-hints {
display: flex;
gap: var(--spacing-md);
color: var(--text-subtle);
font-size: var(--font-size-xs);
user-select: none;
}
.input-hint {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.key-hint {
padding: 2px 4px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 2px;
font-size: 10px;
}
/* Tool Indicator Styles */
.tool-indicator {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.tool-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid var(--border-default);
border-top-color: var(--dot-tool);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.tool-name {
color: var(--dot-tool);
font-weight: 500;
}
.tool-status {
color: var(--text-muted);
}
.tool-complete {
color: var(--color-success);
}
.tool-error {
color: var(--color-error);
}
/* ASCII Banner Styles */
.ascii-banner {
padding: var(--spacing-xl);
color: var(--text-muted);
font-size: var(--font-size-sm);
line-height: 1.2;
white-space: pre;
font-family: var(--font-mono);
opacity: 0;
animation: fadeIn 0.5s ease-out forwards;
}
@keyframes fadeIn {
to { opacity: 1; }
}
.ascii-banner-title {
color: var(--text-secondary);
}
.ascii-banner-stats {
color: var(--text-subtle);
}
/* Helper Tab Styles */
.helper-tab {
position: relative;
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all var(--transition-fast);
outline: none;
white-space: nowrap;
}
.helper-tab:hover {
background: var(--bg-hover);
color: var(--text-secondary);
}
.helper-tab.active {
background: var(--bg-primary);
border-bottom-color: var(--accent-blue);
color: var(--text-primary);
}
/* Scrollbar Styles */
.terminal-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.terminal-scrollbar::-webkit-scrollbar-track {
background: var(--bg-primary);
}
.terminal-scrollbar::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 2px;
}
.terminal-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--text-subtle);
}
/* Loading States */
.loading-dots {
display: inline-flex;
gap: 2px;
}
.loading-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--text-muted);
animation: loadingPulse 1.4s infinite ease-in-out both;
}
.loading-dot:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes loadingPulse {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* Focus States for Accessibility */
.terminal-focusable:focus-visible {
outline: 2px solid var(--accent-blue);
outline-offset: 2px;
}
/* Utility Classes */
.terminal-text-muted {
color: var(--text-muted);
}
.terminal-text-subtle {
color: var(--text-subtle);
}
.terminal-text-error {
color: var(--color-error);
}
.terminal-text-success {
color: var(--color-success);
}
.terminal-bg-surface {
background: var(--bg-surface);
}
.terminal-border-default {
border-color: var(--border-default);
}
-250
View File
@@ -1,250 +0,0 @@
/**
* Terminal Theme Constants for ra-h
* Centralized styling for terminal-inspired UI elements
*/
import { CSSProperties } from 'react';
// Terminal Color Palette
export const TERMINAL_COLORS = {
// Message indicators (Claude Code style)
user: '#3b82f6', // Blue dot for user messages
assistant: '#10b981', // Green dot for assistant messages
tool: '#f59e0b', // Orange dot for tool use
processing: '#eab308', // Yellow dot for thinking/processing states
// Terminal elements
prompt: '#10b981', // Terminal green for $ prompt
accent: '#10b981', // Primary terminal accent color
// Base colors (terminal-inspired palette)
bg: {
primary: '#000', // Main background
secondary: '#000', // Secondary background (input area) - same as primary
elevated: '#000', // Elevated surfaces (message bubbles) - same as primary for borderless look
},
text: {
primary: '#d1d5db', // Primary text
secondary: '#666', // Muted text
tertiary: '#444', // Very muted text
},
border: {
primary: '#333', // Primary borders
secondary: '#222', // Subtle borders
}
} as const;
// Terminal Typography
export const TERMINAL_FONTS = {
mono: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
system: 'inherit'
} as const;
// Terminal Component Styles
export const TERMINAL_STYLES = {
// Message header with colored dot indicator
messageHeader: (role: 'user' | 'assistant' | 'tool'): CSSProperties => ({
fontSize: '10px',
color: TERMINAL_COLORS.text.secondary,
marginBottom: '4px',
textTransform: 'uppercase' as const,
letterSpacing: '0.05em',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontFamily: TERMINAL_FONTS.mono
}),
// Colored dot indicator
messageIndicator: (role: 'user' | 'assistant' | 'tool' | 'processing'): CSSProperties => ({
fontSize: '8px',
color: TERMINAL_COLORS[role],
width: '6px',
height: '6px',
flexShrink: 0
}),
// Message bubble (borderless for cleaner terminal feel)
messageBubble: (role: 'user' | 'assistant'): CSSProperties => ({
background: role === 'user' ? TERMINAL_COLORS.bg.elevated : TERMINAL_COLORS.bg.secondary,
border: 'none', // Removed borders for cleaner terminal look
borderRadius: '0px', // Sharp terminal corners
padding: '12px 16px',
maxWidth: '80%',
fontSize: '13px',
lineHeight: '1.5',
color: TERMINAL_COLORS.text.primary,
whiteSpace: 'pre-wrap' as const,
fontFamily: TERMINAL_FONTS.mono
}),
// Message timestamp
messageTimestamp: (): CSSProperties => ({
fontSize: '9px',
color: TERMINAL_COLORS.text.tertiary,
marginTop: '6px',
fontFamily: TERMINAL_FONTS.mono
}),
// Terminal input container
terminalInputContainer: (): CSSProperties => ({
position: 'relative' as const,
display: 'flex',
alignItems: 'center',
flex: 1
}),
// Terminal prompt symbol
terminalPrompt: (): CSSProperties => ({
position: 'absolute' as const,
left: '12px',
color: TERMINAL_COLORS.prompt,
fontSize: '13px',
fontFamily: TERMINAL_FONTS.mono,
zIndex: 1,
pointerEvents: 'none' as const
}),
// Terminal input field (subtle border, auto-expanding)
terminalInput: (): CSSProperties => ({
width: '100%',
background: TERMINAL_COLORS.bg.elevated,
border: `1px solid ${TERMINAL_COLORS.border.secondary}`, // More subtle border
borderRadius: '4px',
padding: '8px 12px 8px 24px', // Extra left padding for $ symbol
fontSize: '13px',
color: TERMINAL_COLORS.text.primary,
fontFamily: TERMINAL_FONTS.mono,
resize: 'none' as const,
minHeight: '36px',
maxHeight: '120px', // Increased max height
outline: 'none',
transition: 'border-color 0.2s, height 0.1s ease',
overflow: 'hidden' // For auto-resize
}),
// Terminal send button
terminalButton: (disabled: boolean = false): CSSProperties => ({
padding: '8px 16px',
background: disabled ? TERMINAL_COLORS.border.primary : TERMINAL_COLORS.accent,
color: disabled ? TERMINAL_COLORS.text.secondary : '#fff',
border: `1px solid ${TERMINAL_COLORS.border.primary}`,
borderRadius: '4px',
fontSize: '13px',
cursor: disabled ? 'not-allowed' : 'pointer',
fontFamily: TERMINAL_FONTS.mono,
transition: 'all 0.2s',
display: 'flex',
alignItems: 'center',
gap: '4px'
}),
// Chat messages container
messagesContainer: (): CSSProperties => ({
flex: 1,
overflow: 'auto',
padding: '16px',
fontFamily: TERMINAL_FONTS.mono
}),
// Thinking indicator
thinkingIndicator: (): CSSProperties => ({
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px 16px',
background: TERMINAL_COLORS.bg.secondary,
borderRadius: '0px',
margin: '8px 0',
fontSize: '12px',
color: TERMINAL_COLORS.text.secondary,
fontFamily: TERMINAL_FONTS.mono,
fontStyle: 'italic'
}),
// Tool use indicator (separate from messages)
toolIndicator: (): CSSProperties => ({
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
background: 'rgba(245, 158, 11, 0.1)', // Orange background with transparency
border: `1px solid ${TERMINAL_COLORS.tool}`,
borderRadius: '0px',
margin: '4px 0',
fontSize: '11px',
color: TERMINAL_COLORS.tool,
fontFamily: TERMINAL_FONTS.mono
}),
// Input form container (remove border)
inputForm: (): CSSProperties => ({
borderTop: 'none', // Remove border between chat and input
padding: '16px',
background: TERMINAL_COLORS.bg.primary // Same as main background for seamless look
})
} as const;
// Terminal Tool Indicators
export const TERMINAL_TOOLS = {
processing: '⟡ Processing...',
generating: '▸ Generating response...',
thinking: (displayName: string) => `${displayName} is thinking...`,
toolPrefix: '⟩',
thinkingDots: '⋯'
} as const;
// Terminal Animations (for use in CSS)
export const TERMINAL_ANIMATIONS = {
pulse: 'pulse 1.5s ease-in-out infinite',
typing: 'typing 1s ease-in-out infinite'
} as const;
// Helper function to get role-based styling
export const getMessageStyles = (role: 'user' | 'assistant', isLoading?: boolean) => ({
header: TERMINAL_STYLES.messageHeader(role),
indicator: TERMINAL_STYLES.messageIndicator(isLoading ? 'processing' : role),
bubble: TERMINAL_STYLES.messageBubble(role),
timestamp: TERMINAL_STYLES.messageTimestamp()
});
// Helper function to get input styling with focus states
export const getTerminalInputStyles = (isFocused: boolean = false) => ({
container: TERMINAL_STYLES.terminalInputContainer(),
prompt: TERMINAL_STYLES.terminalPrompt(),
input: {
...TERMINAL_STYLES.terminalInput(),
borderColor: isFocused ? TERMINAL_COLORS.accent : TERMINAL_COLORS.border.secondary
}
});
// Auto-resize textarea helper function
export const autoResizeTextarea = (textarea: HTMLTextAreaElement) => {
textarea.style.height = 'auto';
const newHeight = Math.min(textarea.scrollHeight, 120); // Max 120px
textarea.style.height = `${Math.max(newHeight, 36)}px`; // Min 36px
};
// Thinking indicator component props
export const getThinkingIndicator = (displayName: string, isVisible: boolean) => {
if (!isVisible) return null;
return {
style: TERMINAL_STYLES.thinkingIndicator(),
content: TERMINAL_TOOLS.thinking(displayName),
dots: TERMINAL_TOOLS.thinkingDots
};
};
// Tool use indicator component props
export const getToolIndicator = (toolName: string, isActive: boolean) => {
if (!isActive) return null;
return {
style: TERMINAL_STYLES.toolIndicator(),
content: `${TERMINAL_TOOLS.toolPrefix} ${toolName}`,
isActive
};
};
-62
View File
@@ -1,62 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
export const lockDimensionTool = tool({
description: 'Lock a dimension to enable auto-assignment to new nodes',
inputSchema: z.object({
name: z.string().describe('Dimension name to lock')
}),
execute: async (params) => {
console.log('🔒 LockDimension tool called with params:', JSON.stringify(params, null, 2));
try {
const trimmedName = params.name.trim();
if (!trimmedName) {
return {
success: false,
error: 'Dimension name is required',
data: null
};
}
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: trimmedName,
isPriority: true
})
});
if (!response.ok) {
let errorMessage = 'Failed to lock dimension';
try {
const errorResult = await response.json();
errorMessage = errorResult.error || errorMessage;
} catch {
// If response is not JSON (e.g., HTML error page), use status text
errorMessage = `Failed to lock dimension: ${response.status} ${response.statusText}`;
}
return {
success: false,
error: errorMessage,
data: null
};
}
const result = await response.json();
return {
success: true,
data: result.data,
message: `Locked dimension "${trimmedName}" - it will now be auto-assigned to new nodes`
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to lock dimension',
data: null
};
}
}
});
-62
View File
@@ -1,62 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
export const unlockDimensionTool = tool({
description: 'Unlock a dimension to disable auto-assignment to new nodes',
inputSchema: z.object({
name: z.string().describe('Dimension name to unlock')
}),
execute: async (params) => {
console.log('🔓 UnlockDimension tool called with params:', JSON.stringify(params, null, 2));
try {
const trimmedName = params.name.trim();
if (!trimmedName) {
return {
success: false,
error: 'Dimension name is required',
data: null
};
}
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: trimmedName,
isPriority: false
})
});
if (!response.ok) {
let errorMessage = 'Failed to unlock dimension';
try {
const errorResult = await response.json();
errorMessage = errorResult.error || errorMessage;
} catch {
// If response is not JSON (e.g., HTML error page), use status text
errorMessage = `Failed to unlock dimension: ${response.status} ${response.statusText}`;
}
return {
success: false,
error: errorMessage,
data: null
};
}
const result = await response.json();
return {
success: true,
data: result.data,
message: `Unlocked dimension "${trimmedName}" - it will no longer be auto-assigned to new nodes`
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to unlock dimension',
data: null
};
}
}
});
-41
View File
@@ -1,41 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
export const embedContentTool = tool({
description: 'Chunk and embed a nodes content so semantic search stays current',
inputSchema: z.object({
nodeId: z.number().describe('The ID of the node to process')
}),
execute: async ({ nodeId }) => {
try {
// Call the same API endpoint that the manual embed button uses
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/ingestion`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nodeId })
});
const result = await response.json();
if (!response.ok) {
return {
success: false,
error: result.error || 'Failed to embed content',
data: null
};
}
return {
success: true,
message: result.message || `Successfully chunked and embedded content for node ${nodeId}`,
data: { nodeId }
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to embed content',
data: null
};
}
}
});
-95
View File
@@ -23,92 +23,6 @@ export interface UsageData {
mode?: 'easy' | 'hard';
}
export interface EnhancedChatMetadata {
timestamp: string;
session_id: string;
current_view: 'nodes' | 'memory';
open_tab_count: number;
has_focused_node: boolean;
message_count: number;
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
cache_write_tokens?: number;
cache_read_tokens?: number;
cache_hit?: boolean;
cache_savings_pct?: number;
estimated_cost_usd?: number;
model_used?: string;
provider?: 'anthropic' | 'openai';
tools_used?: string[];
tool_calls_count?: number;
trace_id?: string;
parent_chat_id?: number;
voice_tts_chars?: number;
voice_tts_cost_usd?: number;
voice_tts_chars_total?: number;
voice_tts_cost_usd_total?: number;
voice_request_id?: string;
voice_tts_request_count?: number;
voice_usage?: Array<{
request_id: string;
message_id?: string | null;
chars: number;
cost_usd: number;
voice?: string;
model?: string;
duration_ms?: number | null;
logged_at?: string;
}>;
}
export interface CostReport {
periodStart: string;
periodEnd: string;
totalCostUsd: number;
totalChats: number;
totalTokens: number;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
cacheHitRate: number;
cacheSavingsUsd: number;
avgCostPerChat: number;
avgTokensPerChat: number;
costByAgent: {
[agentName: string]: {
costUsd: number;
chats: number;
tokens: number;
};
};
costByModel: {
[modelId: string]: {
costUsd: number;
chats: number;
tokens: number;
};
};
}
export interface TraceCostSummary {
traceId: string;
totalCostUsd: number;
chatCount: number;
orchestratorCost: number;
executorCost: number;
plannerCost: number;
totalTokens: number;
interactions: Array<{
chatId: number;
agentName: string;
costUsd: number;
tokens: number;
createdAt: string;
}>;
}
export interface ModelPricing {
provider: 'anthropic' | 'openai';
inputPer1M: number;
@@ -117,12 +31,3 @@ export interface ModelPricing {
cacheReadPer1M?: number;
}
export interface CacheEffectiveness {
totalRequests: number;
cacheHits: number;
cacheMisses: number;
hitRate: number;
totalCacheSavingsUsd: number;
avgSavingsPerHit: number;
totalTokensSaved: number;
}
-84
View File
@@ -21,27 +21,6 @@ export interface Node {
chunk_status?: 'not_chunked' | 'chunking' | 'chunked' | 'error' | null;
}
// Legacy Item interface - DEPRECATED, use Node instead
// Kept temporarily for migration compatibility
export interface Item extends Node {
// Legacy fields for backwards compatibility during transition
description?: string;
abstract?: string;
type?: string;
legacyType?: string[];
stage?: string;
segment?: string[];
tags?: string[];
sub_type?: any;
notes?: any;
extras?: any;
score?: number;
content_embedding?: number[];
embedding_updated_at?: string;
chunk_status?: 'not_chunked' | 'chunking' | 'chunked' | 'error';
chunk_updated_at?: string;
}
export interface Chunk {
id: number;
node_id: number; // Updated from item_id to node_id
@@ -85,36 +64,6 @@ export interface EdgeContext {
created_via: EdgeCreatedVia;
}
export interface Chat {
id: number;
user_message?: string;
assistant_message?: string;
thread_id: string;
focused_node_id?: number; // Updated from focused_item_id
metadata?: any;
embedding?: number[]; // Renamed from content_embedding
created_at: string;
}
export interface SessionContext {
id: number;
session_id: string;
focused_node_id: number; // Updated from focused_item_id
context_data: any;
created_at: string;
last_accessed: string;
expires_at: string;
}
export interface SessionCache {
id: number;
session_id: string;
cache_key: string;
cache_data: any;
expires_at: string;
created_at: string;
}
// New NodeFilters interface replacing rigid ItemFilters
export interface NodeFilters {
dimensions?: string[]; // Filter by dimensions (replaces stage/type filtering)
@@ -124,13 +73,6 @@ export interface NodeFilters {
sortBy?: 'updated' | 'edges'; // Sort by updated_at or edge count
}
// Legacy filters - DEPRECATED, use NodeFilters instead
export interface ItemFilters extends NodeFilters {
stage?: string;
type?: string;
tags?: string[];
}
export interface ChunkData {
node_id: number; // Updated from item_id
chunk_idx?: number;
@@ -158,13 +100,6 @@ export interface ChatData {
embedding?: number[]; // Renamed from content_embedding
}
export interface CachedContext {
sessionId: string;
focusedNodeId: number; // Updated from focusedItemId
contextData: any;
expiresAt: string;
}
// New NodeConnection interface
export interface NodeConnection {
id: number;
@@ -172,25 +107,6 @@ export interface NodeConnection {
edge: Edge;
}
// Legacy connection - DEPRECATED, use NodeConnection instead
export interface ItemConnection {
id: number;
connected_item: Item;
edge: Edge;
}
export interface DatabaseConfig {
host: string;
port: number;
database: string;
user: string;
password: string;
ssl?: boolean;
connectionTimeoutMillis?: number;
idleTimeoutMillis?: number;
max?: number;
}
export interface DatabaseError {
message: string;
code?: string;
-22
View File
@@ -1,22 +0,0 @@
export interface MemoryContext {
insights: {
user_patterns: string[];
preferences: string[];
common_queries: string[];
knowledge_domains: string[];
};
context: string;
}
export interface HelperConfig {
name: string;
display_name: string;
description: string;
system_prompt: string;
component_key: string;
available_tools: string[];
enabled: boolean;
created_at: string;
updated_at: string;
memory?: MemoryContext;
}
+2 -70
View File
@@ -1,80 +1,12 @@
import type { Config } from "tailwindcss";
const config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
theme: { extend: {} },
plugins: [],
} satisfies Config;
export default config;