Initial commit: RA-H Open Source Edition

Local-first knowledge management system with BYO API keys.

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

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

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

MIT License
This commit is contained in:
“BeeRad”
2025-12-15 16:14:28 +11:00
commit 733d1c3407
226 changed files with 46231 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
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 }
);
}
}
+105
View File
@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from 'next/server';
import { isSubscriptionBackendEnabled } from '@/config/runtime';
const BACKEND_BASE_URL = (process.env.BACKEND_SERVICE_URL || process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001').replace(/\/$/, '');
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
async function proxyRequest(
request: NextRequest,
context: { params: Promise<{ endpoint?: string[] }> }
) {
if (!isSubscriptionBackendEnabled()) {
return NextResponse.json({ error: 'Subscription backend disabled' }, { status: 404 });
}
if (!BACKEND_BASE_URL) {
return NextResponse.json({ error: 'Backend URL is not configured' }, { status: 500 });
}
const { endpoint: pathSegments = [] } = await context.params;
const targetPath = pathSegments.join('/');
const search = request.nextUrl.search || '';
const targetUrl = `${BACKEND_BASE_URL}${targetPath ? `/${targetPath}` : ''}${search}`;
const outgoingHeaders = new Headers();
const authHeader = request.headers.get('authorization');
const contentType = request.headers.get('content-type');
const clientInfo = request.headers.get('x-client-info');
if (authHeader) {
outgoingHeaders.set('authorization', authHeader);
}
if (contentType) {
outgoingHeaders.set('content-type', contentType);
}
if (clientInfo) {
outgoingHeaders.set('x-client-info', clientInfo);
}
outgoingHeaders.set('accept', request.headers.get('accept') || 'application/json');
const bodyAllowed = !['GET', 'HEAD', 'OPTIONS'].includes(request.method.toUpperCase());
const body = bodyAllowed ? await request.text() : undefined;
try {
const backendResponse = await fetch(targetUrl, {
method: request.method,
headers: outgoingHeaders,
body,
});
const responseHeaders = new Headers();
backendResponse.headers.forEach((value, key) => {
const lowerKey = key.toLowerCase();
if (['content-length', 'transfer-encoding', 'connection'].includes(lowerKey)) {
return;
}
responseHeaders.set(key, value);
});
responseHeaders.set('cache-control', 'no-store');
if (request.method.toUpperCase() === 'HEAD') {
return new NextResponse(null, {
status: backendResponse.status,
headers: responseHeaders,
});
}
const text = await backendResponse.text();
return new NextResponse(text, {
status: backendResponse.status,
headers: responseHeaders,
});
} catch (error) {
console.error('[backend-proxy] Failed to reach backend:', error);
return NextResponse.json(
{ error: 'Failed to contact backend service' },
{ status: 502 }
);
}
}
export function GET(request: NextRequest, context: { params: Promise<{ endpoint?: string[] }> }) {
return proxyRequest(request, context);
}
export function POST(request: NextRequest, context: { params: Promise<{ endpoint?: string[] }> }) {
return proxyRequest(request, context);
}
export function PUT(request: NextRequest, context: { params: Promise<{ endpoint?: string[] }> }) {
return proxyRequest(request, context);
}
export function PATCH(request: NextRequest, context: { params: Promise<{ endpoint?: string[] }> }) {
return proxyRequest(request, context);
}
export function DELETE(request: NextRequest, context: { params: Promise<{ endpoint?: string[] }> }) {
return proxyRequest(request, context);
}
export function OPTIONS(request: NextRequest, context: { params: Promise<{ endpoint?: string[] }> }) {
return proxyRequest(request, context);
}
+67
View File
@@ -0,0 +1,67 @@
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 }
);
}
}
+109
View File
@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { eventBroadcaster } from '@/services/events';
export const runtime = 'nodejs';
export async function GET() {
try {
return getPopularDimensionsSQLite();
} catch (error) {
console.error('Error fetching popular dimensions:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch popular dimensions'
}, { status: 500 });
}
}
// PostgreSQL path removed in SQLite-only consolidation
async function getPopularDimensionsSQLite() {
const sqlite = getSQLiteClient();
const result = sqlite.query(`
WITH dimension_counts AS (
SELECT nd.dimension, COUNT(*) AS count
FROM node_dimensions nd
GROUP BY nd.dimension
),
all_dimensions AS (
SELECT DISTINCT dimension AS name FROM node_dimensions
UNION
SELECT name FROM dimensions
)
SELECT ad.name AS dimension,
COALESCE(dc.count, 0) AS count,
COALESCE(dim.is_priority, 0) AS is_priority,
dim.description
FROM all_dimensions ad
LEFT JOIN dimension_counts dc ON dc.dimension = ad.name
LEFT JOIN dimensions dim ON dim.name = ad.name
WHERE ad.name IS NOT NULL
ORDER BY is_priority DESC, LOWER(ad.name) ASC
`);
return NextResponse.json({
success: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: result.rows.map((row: any) => ({
dimension: row.dimension,
count: Number(row.count),
isPriority: Boolean(row.is_priority),
description: row.description || null
}))
});
}
export async function POST(request: NextRequest) {
try {
const { dimension } = await request.json();
if (!dimension || typeof dimension !== 'string') {
return NextResponse.json({
success: false,
error: 'Dimension name is required'
}, { status: 400 });
}
return togglePrioritySQLite(dimension);
} catch (error) {
console.error('Error toggling dimension priority:', error);
return NextResponse.json({
success: false,
error: 'Internal server error'
}, { status: 500 });
}
}
// PostgreSQL path removed in SQLite-only consolidation
async function togglePrioritySQLite(dimension: string) {
const sqlite = getSQLiteClient();
const result = sqlite.query(`
INSERT INTO dimensions(name, is_priority, updated_at)
VALUES (?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
is_priority = CASE WHEN is_priority=1 THEN 0 ELSE 1 END,
updated_at = CURRENT_TIMESTAMP
RETURNING is_priority
`, [dimension]);
const isPriority = Boolean(result.rows[0].is_priority);
// Broadcast dimension update event
eventBroadcaster.broadcast({
type: 'DIMENSION_UPDATED',
data: { dimension, isPriority }
});
return NextResponse.json({
success: true,
data: {
dimension,
is_priority: isPriority
}
});
}
+326
View File
@@ -0,0 +1,326 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { eventBroadcaster } from '@/services/events';
import { DimensionService } from '@/services/database/dimensionService';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
const description = typeof body?.description === 'string' ? body.description.trim() : null;
const isPriority = typeof body?.isPriority === 'boolean' ? body.isPriority : false;
if (!rawName) {
return NextResponse.json({
success: false,
error: 'Dimension name is required'
}, { status: 400 });
}
if (description && description.length > 500) {
return NextResponse.json({
success: false,
error: 'Description must be 500 characters or less'
}, { status: 400 });
}
const sqlite = getSQLiteClient();
const result = sqlite.query(`
INSERT INTO dimensions(name, description, is_priority, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
description = COALESCE(?, description),
is_priority = COALESCE(?, is_priority),
updated_at = CURRENT_TIMESTAMP
RETURNING name, description, is_priority
`, [rawName, description, isPriority ? 1 : 0, description, isPriority ? 1 : 0]);
if (result.rows.length === 0) {
throw new Error('Failed to create dimension');
}
const row = result.rows[0];
const dimension = row.name as string;
const isPriorityValue = Boolean(row.is_priority);
const descriptionValue = row.description as string | null;
eventBroadcaster.broadcast({
type: 'DIMENSION_UPDATED',
data: { dimension, isPriority: isPriorityValue, description: descriptionValue, count: 0 }
});
return NextResponse.json({
success: true,
data: {
dimension,
description: descriptionValue,
isPriority: isPriorityValue
}
});
} catch (error) {
console.error('Error creating dimension:', error);
return NextResponse.json({
success: false,
error: 'Failed to create dimension'
}, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const currentName = typeof body?.currentName === 'string' ? body.currentName.trim() : '';
const newName = typeof body?.newName === 'string' ? body.newName.trim() : '';
const name = typeof body?.name === 'string' ? body.name.trim() : '';
const description = typeof body?.description === 'string' ? body.description.trim() : '';
const isPriority = typeof body?.isPriority === 'boolean' ? body.isPriority : undefined;
// Handle isPriority update (lock/unlock) - simple case
if (isPriority !== undefined && name && !currentName && !newName) {
const sqlite = getSQLiteClient();
const updateResult = sqlite.prepare(`
UPDATE dimensions
SET is_priority = ?, updated_at = CURRENT_TIMESTAMP
WHERE name = ?
`).run(isPriority ? 1 : 0, name);
if (updateResult.changes === 0) {
return NextResponse.json({
success: false,
error: 'Dimension not found'
}, { status: 404 });
}
eventBroadcaster.broadcast({
type: 'DIMENSION_UPDATED',
data: { dimension: name, isPriority }
});
return NextResponse.json({
success: true,
data: { dimension: name, isPriority }
});
}
// Handle dimension name change
if (currentName && newName && currentName !== newName) {
if (!newName) {
return NextResponse.json({
success: false,
error: 'New dimension name is required'
}, { status: 400 });
}
const sqlite = getSQLiteClient();
// Check if new name already exists
const existingCheck = sqlite.query(`
SELECT name FROM dimensions WHERE name = ?
`, [newName]);
if (existingCheck.rows.length > 0) {
return NextResponse.json({
success: false,
error: 'A dimension with this name already exists'
}, { status: 400 });
}
// Update dimension name in transaction (also handle description and isPriority if provided)
const updateResult = sqlite.transaction(() => {
// Build update query with optional fields
const updates: string[] = ['name = ?', 'updated_at = CURRENT_TIMESTAMP'];
const values: any[] = [newName];
if (description !== '') {
updates.push('description = ?');
values.push(description || null);
}
if (isPriority !== undefined) {
updates.push('is_priority = ?');
values.push(isPriority ? 1 : 0);
}
values.push(currentName);
const dimUpdate = sqlite.prepare(`
UPDATE dimensions
SET ${updates.join(', ')}
WHERE name = ?
`).run(...values);
// Update node_dimensions table
const nodeDimUpdate = sqlite.prepare(`
UPDATE node_dimensions
SET dimension = ?
WHERE dimension = ?
`).run(newName, currentName);
return {
dimensionUpdated: dimUpdate.changes > 0,
nodeLinksUpdated: nodeDimUpdate.changes
};
});
if (!updateResult.dimensionUpdated) {
return NextResponse.json({
success: false,
error: 'Dimension not found'
}, { status: 404 });
}
eventBroadcaster.broadcast({
type: 'DIMENSION_UPDATED',
data: {
dimension: newName,
previousName: currentName,
description: description || undefined,
isPriority: isPriority !== undefined ? isPriority : undefined,
renamed: true
}
});
return NextResponse.json({
success: true,
data: {
dimension: newName,
previousName: currentName,
description: description || undefined,
isPriority: isPriority !== undefined ? isPriority : undefined,
nodeLinksUpdated: updateResult.nodeLinksUpdated
}
});
}
// Handle description and/or isPriority update (existing functionality)
const targetName = name || currentName;
if (!targetName) {
return NextResponse.json({
success: false,
error: 'Dimension name is required'
}, { status: 400 });
}
if (description && description.length > 500) {
return NextResponse.json({
success: false,
error: 'Description must be 500 characters or less'
}, { status: 400 });
}
const sqlite = getSQLiteClient();
// Build update query
if (description !== '' || isPriority !== undefined) {
const updates: string[] = ['updated_at = CURRENT_TIMESTAMP'];
const values: any[] = [];
if (description !== '') {
updates.push('description = ?');
values.push(description || null);
}
if (isPriority !== undefined) {
updates.push('is_priority = ?');
values.push(isPriority ? 1 : 0);
}
values.push(targetName);
const updateResult = sqlite.prepare(`
UPDATE dimensions
SET ${updates.join(', ')}
WHERE name = ?
`).run(...values);
if (updateResult.changes === 0) {
return NextResponse.json({
success: false,
error: 'Dimension not found'
}, { status: 404 });
}
} else {
// No updates provided
return NextResponse.json({
success: false,
error: 'At least one update field (description or isPriority) must be provided'
}, { status: 400 });
}
eventBroadcaster.broadcast({
type: 'DIMENSION_UPDATED',
data: {
dimension: targetName,
description: description !== '' ? description : undefined,
isPriority: isPriority !== undefined ? isPriority : undefined
}
});
return NextResponse.json({
success: true,
data: {
dimension: targetName,
description: description !== '' ? description : undefined,
isPriority: isPriority !== undefined ? isPriority : undefined
}
});
} catch (error) {
console.error('Error updating dimension:', error);
return NextResponse.json({
success: false,
error: 'Failed to update dimension'
}, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
const dimension = (request.nextUrl.searchParams.get('name') || '').trim();
if (!dimension) {
return NextResponse.json({
success: false,
error: 'Dimension name is required'
}, { status: 400 });
}
const sqlite = getSQLiteClient();
const removal = sqlite.transaction(() => {
const nodeDimStmt = sqlite.prepare('DELETE FROM node_dimensions WHERE dimension = ?');
const dimStmt = sqlite.prepare('DELETE FROM dimensions WHERE name = ?');
const removedLinks = nodeDimStmt.run(dimension).changes ?? 0;
const removedRow = dimStmt.run(dimension).changes ?? 0;
return {
removedLinks,
removedRow
};
});
if (!removal.removedLinks && !removal.removedRow) {
return NextResponse.json({
success: false,
error: 'Dimension not found'
}, { status: 404 });
}
eventBroadcaster.broadcast({
type: 'DIMENSION_UPDATED',
data: { dimension, deleted: true }
});
return NextResponse.json({
success: true,
data: {
dimension,
deleted: true
}
});
} catch (error) {
console.error('Error deleting dimension:', error);
return NextResponse.json({
success: false,
error: 'Failed to delete dimension'
}, { status: 500 });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSQLiteClient } from '@/services/database/sqlite-client';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q') || '';
if (!query.trim()) {
return NextResponse.json({
success: false,
error: 'Search query is required'
}, { status: 400 });
}
return searchDimensionsSQLite(query);
} catch (error) {
console.error('Error searching dimensions:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to search dimensions'
}, { status: 500 });
}
}
// PostgreSQL path removed in SQLite-only consolidation
async function searchDimensionsSQLite(query: string) {
const sqlite = getSQLiteClient();
const result = sqlite.query(`
SELECT nd.dimension, COUNT(*) AS count
FROM node_dimensions nd
WHERE LOWER(nd.dimension) LIKE LOWER(?)
GROUP BY nd.dimension
ORDER BY count DESC, nd.dimension ASC
LIMIT 20
`, [`%${query}%`]);
return NextResponse.json({
success: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: result.rows.map((row: any) => ({
dimension: row.dimension,
count: Number(row.count)
}))
});
}
+115
View File
@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from 'next/server';
import { edgeService } from '@/services/database';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const edgeId = parseInt(id, 10);
if (isNaN(edgeId)) {
return NextResponse.json({
success: false,
error: 'Invalid edge ID'
}, { status: 400 });
}
const edge = await edgeService.getEdgeById(edgeId);
if (!edge) {
return NextResponse.json({
success: false,
error: 'Edge not found'
}, { status: 404 });
}
return NextResponse.json({
success: true,
data: edge
});
} catch (error) {
console.error('Error fetching edge:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch edge'
}, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const edgeId = parseInt(id, 10);
if (isNaN(edgeId)) {
return NextResponse.json({
success: false,
error: 'Invalid edge ID'
}, { status: 400 });
}
const body = await request.json();
// Validate source value if provided
if (body.source && !['user', 'ai_similarity', 'helper_name'].includes(body.source)) {
return NextResponse.json({
success: false,
error: 'Invalid source: must be user, ai_similarity, or helper_name'
}, { status: 400 });
}
const edge = await edgeService.updateEdge(edgeId, body);
return NextResponse.json({
success: true,
data: edge,
message: `Edge updated successfully`
});
} catch (error) {
console.error('Error updating edge:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to update edge'
}, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const edgeId = parseInt(id, 10);
if (isNaN(edgeId)) {
return NextResponse.json({
success: false,
error: 'Invalid edge ID'
}, { status: 400 });
}
await edgeService.deleteEdge(edgeId);
return NextResponse.json({
success: true,
message: `Edge ${edgeId} deleted successfully`
});
} catch (error) {
console.error('Error deleting edge:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to delete edge'
}, { status: 500 });
}
}
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { edgeService } from '@/services/database';
export const runtime = 'nodejs';
export async function GET() {
try {
const edges = await edgeService.getEdges();
return NextResponse.json({
success: true,
data: edges,
count: edges.length
});
} catch (error) {
console.error('Error fetching edges:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch edges'
}, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate required fields
if (!body.from_node_id || !body.to_node_id) {
return NextResponse.json({
success: false,
error: 'Missing required fields: from_node_id and to_node_id are required'
}, { status: 400 });
}
// Validate node IDs are numbers
if (isNaN(parseInt(body.from_node_id)) || isNaN(parseInt(body.to_node_id))) {
return NextResponse.json({
success: false,
error: 'Invalid node IDs: must be valid numbers'
}, { status: 400 });
}
// Set default source if not provided
if (!body.source) {
body.source = 'user';
}
// Validate source value
if (!['user', 'ai_similarity', 'helper_name'].includes(body.source)) {
return NextResponse.json({
success: false,
error: 'Invalid source: must be user, ai_similarity, or helper_name'
}, { status: 400 });
}
const fromId = parseInt(body.from_node_id);
const toId = parseInt(body.to_node_id);
// Idempotency: prevent duplicate edges between same pair
try {
const exists = await edgeService.edgeExists(fromId, toId);
if (exists) {
return NextResponse.json({
success: true,
data: { from_node_id: fromId, to_node_id: toId },
message: `Edge already exists between nodes ${fromId} and ${toId}`
}, { status: 200 });
}
} catch (e) {
// Non-fatal: continue with creation if existence check fails
console.warn('edgeExists check failed; proceeding to create:', e);
}
const edge = await edgeService.createEdge({
from_node_id: fromId,
to_node_id: toId,
context: body.context || {},
source: body.source
});
return NextResponse.json({
success: true,
data: edge,
message: `Edge created successfully between nodes ${edge.from_node_id} and ${edge.to_node_id}`
}, { status: 201 });
} catch (error) {
console.error('Error creating edge:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to create edge'
}, { status: 500 });
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Server-Sent Events (SSE) API Route
* Streams real-time database change events to connected clients
*/
import { eventBroadcaster } from '@/services/events';
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// Add this connection to the broadcaster
console.log('🔌 New SSE connection established');
eventBroadcaster.addConnection(controller);
console.log('📊 Total SSE connections:', eventBroadcaster.getConnectionCount());
// Send initial connection confirmation
const initialMessage = `data: ${JSON.stringify({
type: 'CONNECTION_ESTABLISHED',
data: { timestamp: Date.now() }
})}\n\n`;
controller.enqueue(encoder.encode(initialMessage));
// Store controller reference for cleanup
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(controller as any)._cleanup = () => {
console.log('🔌 SSE connection cleanup');
eventBroadcaster.removeConnection(controller);
};
},
cancel(controller) {
// Clean up when client disconnects
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((controller as any)._cleanup) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(controller as any)._cleanup();
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
},
});
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { checkDatabaseHealth } from '@/services/database';
export const runtime = 'nodejs';
export async function GET() {
try {
const status = await checkDatabaseHealth();
return NextResponse.json({ success: true, ...status });
} catch (error) {
return NextResponse.json({
success: false,
connected: false,
vectorExtension: false,
tablesExist: false,
error: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 });
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Simple ping endpoint for sidecar health checks
* Returns a basic OK response to verify the server is responding
*/
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
status: 'ok',
timestamp: new Date().toISOString()
});
}
+120
View File
@@ -0,0 +1,120 @@
import { NextResponse } from 'next/server';
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { chunkService } from '@/services/database/chunks';
export async function GET() {
try {
const sqlite = getSQLiteClient();
// Test basic database connection
const connectionTest = await sqlite.testConnection();
if (!connectionTest) {
return NextResponse.json({
status: 'error',
message: 'Database connection failed',
details: null
});
}
// Check if vector extension is loaded
const vectorExtensionTest = await sqlite.checkVectorExtension();
let vectorStats = null;
let chunkStats = null;
let vectorHealth = 'unknown';
try {
// Get chunk counts
const totalChunks = await chunkService.getChunkCount();
const chunksWithoutEmbeddings = await chunkService.getChunksWithoutEmbeddings();
const vectorizedCount = totalChunks - chunksWithoutEmbeddings.length;
chunkStats = {
total_chunks: totalChunks,
vectorized_chunks: vectorizedCount,
missing_embeddings: chunksWithoutEmbeddings.length,
coverage_percentage: totalChunks > 0 ? Math.round((vectorizedCount / totalChunks) * 100) : 0
};
// Test vector table health by attempting a simple query
if (vectorExtensionTest) {
try {
const result = sqlite.query('SELECT COUNT(*) as count FROM vec_chunks');
const vecCount = Number(result.rows[0].count);
vectorStats = {
vec_chunks_count: vecCount,
matches_chunk_embeddings: vecCount === vectorizedCount
};
vectorHealth = vecCount === vectorizedCount ? 'healthy' : 'inconsistent';
} catch (vecError: any) {
vectorHealth = 'corrupted';
vectorStats = {
error: vecError.message,
suggestion: 'Vector table may be corrupted and need recreation'
};
}
} else {
vectorHealth = 'extension_unavailable';
}
} catch (error: any) {
return NextResponse.json({
status: 'error',
message: 'Failed to collect vector statistics',
details: error.message
});
}
return NextResponse.json({
status: 'success',
data: {
database_connected: connectionTest,
vector_extension_loaded: vectorExtensionTest,
vector_health: vectorHealth,
chunk_stats: chunkStats,
vector_stats: vectorStats,
recommendations: generateRecommendations(vectorHealth, chunkStats, vectorStats)
}
});
} catch (error: any) {
console.error('Vector health check failed:', error);
return NextResponse.json({
status: 'error',
message: 'Health check failed',
details: error.message
});
}
}
function generateRecommendations(
vectorHealth: string,
chunkStats: any,
vectorStats: any
): string[] {
const recommendations: string[] = [];
if (vectorHealth === 'corrupted') {
recommendations.push('Vector tables are corrupted - restart the application to trigger automatic healing');
}
if (vectorHealth === 'extension_unavailable') {
recommendations.push('Vector extension not loaded - check sqlite-vec installation');
}
if (chunkStats && chunkStats.coverage_percentage < 95) {
recommendations.push(`${chunkStats.missing_embeddings} chunks missing embeddings - consider running embedding generation`);
}
if (vectorStats && !vectorStats.matches_chunk_embeddings) {
recommendations.push('Vector count does not match chunk embeddings - database inconsistency detected');
}
if (recommendations.length === 0) {
recommendations.push('Vector search system is healthy');
}
return recommendations;
}
+50
View File
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { nodeService } from '@/services/database';
import { eventBroadcaster } from '@/services/events';
import { embedNodeContent } from '@/services/embedding/ingestion';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate required fields
if (!body.nodeId) {
return NextResponse.json({
success: false,
error: 'Missing required field: nodeId'
}, { status: 400 });
}
// Get the node to validate it exists
const node = await nodeService.getNodeById(body.nodeId);
if (!node) {
return NextResponse.json({
success: false,
error: 'Node not found'
}, { status: 404 });
}
const results = await embedNodeContent(body.nodeId);
if (results.success) {
console.log('📡 Broadcasting NODE_UPDATED for embedding completion');
eventBroadcaster.broadcast({
type: 'NODE_UPDATED',
data: { nodeId: body.nodeId }
});
}
const statusCode = results.success ? 200 : 500;
return NextResponse.json(results, { status: statusCode });
} catch (error) {
console.error('Error in ingestion endpoint:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to process embedding request'
}, { status: 500 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const { apiKey } = await request.json();
if (typeof apiKey !== 'string' || apiKey.trim().length === 0) {
return NextResponse.json(
{ ok: false, error: 'API key is required.' },
{ status: 400 }
);
}
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': apiKey.trim(),
'content-type': 'application/json',
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-3-haiku-20240307',
max_tokens: 1,
messages: [{ role: 'user', content: 'test' }],
}),
});
if (!response.ok) {
const errorBody = await response.text();
return NextResponse.json(
{
ok: false,
status: response.status,
error: errorBody || 'Anthropic returned an error.',
},
{ status: 200 }
);
}
return NextResponse.json({ ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { chunkService } from '@/services/database';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const nodeId = parseInt(id, 10);
if (isNaN(nodeId)) {
return NextResponse.json({
success: false,
error: 'Invalid node ID'
}, { status: 400 });
}
const chunks = await chunkService.getChunksByNodeId(nodeId);
return NextResponse.json({
success: true,
chunks: chunks
});
} catch (error) {
console.error('Error fetching chunks:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch chunks'
}, { status: 500 });
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { edgeService } from '@/services/database';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const nodeId = parseInt(id, 10);
if (isNaN(nodeId)) {
return NextResponse.json({
success: false,
error: 'Invalid node ID'
}, { status: 400 });
}
const connections = await edgeService.getNodeConnections(nodeId);
console.log('[api/nodes/[id]/edges] node', nodeId, 'returned connections:', connections.length);
return NextResponse.json({
success: true,
data: connections,
count: connections.length
});
} catch (error) {
console.error('Error fetching node edges:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch node edges'
}, { status: 500 });
}
}
+150
View File
@@ -0,0 +1,150 @@
import { NextRequest, NextResponse } from 'next/server';
import { nodeService } from '@/services/database';
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
import { hasSufficientContent } from '@/services/embedding/constants';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const nodeId = parseInt(id, 10);
if (isNaN(nodeId)) {
return NextResponse.json({
success: false,
error: 'Invalid node ID'
}, { status: 400 });
}
const node = await nodeService.getNodeById(nodeId);
if (!node) {
return NextResponse.json({
success: false,
error: 'Node not found'
}, { status: 404 });
}
return NextResponse.json({
success: true,
node: node
});
} catch (error) {
console.error('Error fetching node:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch node'
}, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const nodeId = parseInt(id, 10);
if (isNaN(nodeId)) {
return NextResponse.json({
success: false,
error: 'Invalid node ID'
}, { status: 400 });
}
const body = await request.json();
const existingNode = await nodeService.getNodeById(nodeId);
if (!existingNode) {
return NextResponse.json({
success: false,
error: 'Node not found'
}, { status: 404 });
}
if (body && Object.prototype.hasOwnProperty.call(body, 'is_pinned')) {
console.warn(`[nodes/${nodeId}] Ignoring legacy is_pinned payload`);
delete body.is_pinned;
}
const updates: Record<string, unknown> = { ...body };
let shouldQueueEmbed = false;
const incomingChunk = typeof body.chunk === 'string' ? body.chunk : undefined;
const incomingContent = typeof body.content === 'string' ? body.content : undefined;
const existingChunk = existingNode.chunk ?? '';
if (incomingChunk !== undefined) {
const trimmedIncoming = incomingChunk.trim();
const trimmedExisting = existingChunk.trim();
if (!trimmedIncoming) {
updates.chunk_status = null;
} else if (trimmedIncoming !== trimmedExisting) {
updates.chunk_status = 'not_chunked';
shouldQueueEmbed = hasSufficientContent(trimmedIncoming);
} else {
delete updates.chunk_status;
}
} else if (!existingChunk.trim() && hasSufficientContent(incomingContent)) {
updates.chunk = incomingContent;
updates.chunk_status = 'not_chunked';
shouldQueueEmbed = true;
}
const node = await nodeService.updateNode(nodeId, updates);
if (shouldQueueEmbed) {
autoEmbedQueue.enqueue(nodeId, { reason: 'node_updated' });
}
return NextResponse.json({
success: true,
node: node,
message: `Node updated successfully`
});
} catch (error) {
console.error('Error updating node:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to update node'
}, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const nodeId = parseInt(id, 10);
if (isNaN(nodeId)) {
return NextResponse.json({
success: false,
error: 'Invalid node ID'
}, { status: 400 });
}
await nodeService.deleteNode(nodeId);
return NextResponse.json({
success: true,
message: `Node ${nodeId} deleted successfully`
});
} catch (error) {
console.error('Error deleting node:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to delete node'
}, { status: 500 });
}
}
+117
View File
@@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from 'next/server';
import { nodeService } from '@/services/database';
import { Node, NodeFilters } from '@/types/database';
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
import { hasSufficientContent } from '@/services/embedding/constants';
import { DimensionService } from '@/services/database/dimensionService';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const filters: NodeFilters = {
search: searchParams.get('search') || undefined,
limit: searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : 100,
offset: searchParams.get('offset') ? parseInt(searchParams.get('offset')!) : 0
};
// Handle dimensions parameter (comma-separated)
const dimensionsParam = searchParams.get('dimensions');
if (dimensionsParam) {
filters.dimensions = dimensionsParam.split(',').map(dim => dim.trim()).filter(Boolean);
}
// Handle sortBy parameter
const sortByParam = searchParams.get('sortBy');
if (sortByParam === 'edges' || sortByParam === 'updated') {
filters.sortBy = sortByParam;
}
const nodes = await nodeService.getNodes(filters);
return NextResponse.json({
success: true,
data: nodes,
count: nodes.length
});
} catch (error) {
console.error('Error fetching nodes:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch nodes'
}, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate required fields
if (!body.title) {
return NextResponse.json({
success: false,
error: 'Missing required field: title is required'
}, { status: 400 });
}
const rawContent = typeof body.content === 'string' ? body.content : null;
const providedDimensions = Array.isArray(body.dimensions) ? body.dimensions : [];
const trimmedProvidedDimensions = providedDimensions
.map((dim: unknown) => typeof dim === 'string' ? dim.trim() : '')
.filter(Boolean)
.slice(0, 5);
// Auto-assign locked dimensions for all new nodes
const autoAssignedDimensions = await DimensionService.assignLockedDimensions({
title: body.title,
content: rawContent || undefined,
link: body.link
});
// Combine provided and auto-assigned dimensions, remove duplicates
const finalDimensions = [...new Set([...trimmedProvidedDimensions, ...autoAssignedDimensions])]
.slice(0, 5); // Ensure we don't exceed 5 total dimensions
const rawChunk = typeof body.chunk === 'string' ? body.chunk : null;
let chunkToStore = rawChunk;
let chunkStatus: Node['chunk_status'];
if (chunkToStore && chunkToStore.trim().length > 0) {
chunkStatus = 'not_chunked';
} else if (!chunkToStore && hasSufficientContent(rawContent)) {
chunkToStore = rawContent;
chunkStatus = 'not_chunked';
}
const node = await nodeService.createNode({
title: body.title,
content: rawContent ?? undefined,
link: body.link,
dimensions: finalDimensions,
chunk: chunkToStore ?? undefined,
chunk_status: chunkStatus,
metadata: body.metadata || {}
});
if (chunkStatus === 'not_chunked' && node.id) {
autoEmbedQueue.enqueue(node.id, { reason: 'node_created' });
}
return NextResponse.json({
success: true,
data: node,
message: `Node created successfully with dimensions: ${finalDimensions.join(', ')}`
}, { status: 201 });
} catch (error) {
console.error('Error creating node:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to create node'
}, { status: 500 });
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { nodeService } from '@/services/database';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q');
const limit = parseInt(searchParams.get('limit') || '10');
if (!query || query.trim() === '') {
return NextResponse.json({
success: false,
error: 'Missing required parameter: q (search query)'
}, { status: 400 });
}
if (query.length < 2) {
return NextResponse.json({
success: false,
error: 'Search query must be at least 2 characters long'
}, { status: 400 });
}
const nodes = await nodeService.searchNodes(query.trim(), Math.min(limit, 50));
// Return minimal data for edge creation UI
const results = nodes.map(node => ({
id: node.id,
title: node.title,
dimensions: node.dimensions
}));
return NextResponse.json({
success: true,
data: results,
count: results.length,
query: query.trim()
});
} catch (error) {
console.error('Error searching nodes:', error);
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to search nodes'
}, { status: 500 });
}
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { enqueueQuickAdd, QuickAddMode } from '@/services/agents/quickAdd';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { input, mode } = body as { input?: unknown; mode?: unknown };
if (typeof input !== 'string' || input.trim().length === 0) {
return NextResponse.json(
{ success: false, error: 'Input is required' },
{ status: 400 }
);
}
const normalizedMode: QuickAddMode | undefined =
mode === 'link' || mode === 'note' || mode === 'chat' ? mode : undefined;
const delegation = await enqueueQuickAdd({
rawInput: input.trim(),
mode: normalizedMode,
});
return NextResponse.json({ success: true, delegation });
} catch (error) {
console.error('[Quick Add API] Error:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ success: false, error: message },
{ status: 500 }
);
}
}
+465
View File
@@ -0,0 +1,465 @@
import { NextRequest } from 'next/server';
import { streamText, convertToCoreMessages } from 'ai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai';
import { getHelperTools, getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
import { buildSystemPromptBlocks } from '@/services/helpers/contextBuilder';
import { helperLogger } from '@/services/helpers/logger';
import { withChatLogging } from '@/services/chat/middleware';
import { AgentRegistry } from '@/services/agents/registry';
import { calculateCost } from '@/services/analytics/pricing';
import { UsageData } from '@/types/analytics';
import type { CacheStats } from '@/types/prompts';
import { randomUUID } from 'crypto';
import { RequestContext } from '@/services/context/requestContext';
import { isLocalMode } from '@/config/runtime';
export const maxDuration = 900; // 15 minutes (for workflows)
if (isLocalMode()) {
// TODO: add any special local-mode setup if needed later
}
const ANTHROPIC_MODEL_MAP: Record<string, string> = {
'claude-sonnet-4.5': 'claude-sonnet-4-5-20250929',
'claude-3-5-sonnet': 'claude-3-5-sonnet-20241022',
};
type ApiKeyOverrides = {
openai?: string;
anthropic?: string;
};
function resolveModel(modelId: string, apiKeys?: ApiKeyOverrides) {
if (modelId.startsWith('anthropic/')) {
const rawName = modelId.split('/')[1];
const mapped = ANTHROPIC_MODEL_MAP[rawName] || rawName;
const orchestratorKey =
apiKeys?.anthropic ||
process.env.RAH_ORCHESTRATOR_ANTHROPIC_API_KEY ||
process.env.ANTHROPIC_API_KEY;
if (!orchestratorKey) {
throw new Error('RAH_ORCHESTRATOR_ANTHROPIC_API_KEY (or ANTHROPIC_API_KEY) is not set.');
}
const provider = createAnthropic({
apiKey: orchestratorKey,
headers: {
'anthropic-beta': 'prompt-caching-2024-07-31',
},
});
return provider(mapped);
}
if (modelId.startsWith('openai/')) {
const name = modelId.split('/')[1];
const delegateKey =
apiKeys?.openai ||
process.env.RAH_DELEGATE_OPENAI_API_KEY ||
process.env.OPENAI_API_KEY;
if (!delegateKey) {
throw new Error('RAH_DELEGATE_OPENAI_API_KEY (or OPENAI_API_KEY) is not set.');
}
const provider = createOpenAI({ apiKey: delegateKey });
return provider(name);
}
throw new Error(`Unsupported model id: ${modelId}`);
}
// Global cache stats storage for monitoring
declare global {
// eslint-disable-next-line no-var
var lastCacheStats: CacheStats | undefined;
}
type AnthropicUsageLike = {
inputTokens?: number;
outputTokens?: number;
promptTokens?: number;
completionTokens?: number;
cacheCreationInputTokens?: number;
cachedInputTokens?: number;
totalTokens?: number;
cache_write_input_tokens?: number;
cache_read_input_tokens?: number;
cacheWriteInputTokens?: number;
cacheReadInputTokens?: number;
};
function normaliseUsage(entry: AnthropicUsageLike | undefined | null) {
if (!entry) {
return { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
}
const toNumber = (value: unknown): number => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
};
const input = toNumber(entry.inputTokens ?? entry.promptTokens ?? (entry as any).input_tokens ?? 0);
const output = toNumber(entry.outputTokens ?? entry.completionTokens ?? (entry as any).output_tokens ?? 0);
const cacheWrite = toNumber(
entry.cacheCreationInputTokens ??
(entry as any).cache_creation_input_tokens ??
entry.cacheWriteInputTokens ??
entry.cache_write_input_tokens ??
0
);
const cacheRead = toNumber(
entry.cachedInputTokens ??
entry.cacheReadInputTokens ??
(entry as any).cache_read_input_tokens ??
0
);
return { input, output, cacheWrite, cacheRead };
}
function aggregateAnthropicUsage(usage: AnthropicUsageLike | undefined, providerMetadata: any) {
const totals = { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 };
let dataSources = 0;
const add = (entry: AnthropicUsageLike | undefined | null) => {
if (!entry) return;
const { input, output, cacheWrite, cacheRead } = normaliseUsage(entry);
if (input || output || cacheWrite || cacheRead) {
totals.inputTokens += input;
totals.outputTokens += output;
totals.cacheWriteTokens += cacheWrite;
totals.cacheReadTokens += cacheRead;
dataSources += 1;
}
};
const anthropicMeta = providerMetadata?.anthropic;
if (anthropicMeta) {
if (Array.isArray(anthropicMeta.requests)) {
anthropicMeta.requests.forEach((req: any) => {
add(req?.usage ?? req);
});
}
if (anthropicMeta.response?.usage) {
add(anthropicMeta.response.usage);
}
if (anthropicMeta.usage && dataSources === 0) {
add(anthropicMeta.usage);
}
}
if (dataSources === 0) {
add(usage);
}
// Fallback to usage totals if metadata did not provide cache info
if (totals.cacheReadTokens === 0 && usage?.cachedInputTokens) {
totals.cacheReadTokens = normaliseUsage(usage).cacheRead;
}
if (totals.cacheWriteTokens === 0 && usage?.cacheCreationInputTokens) {
totals.cacheWriteTokens = normaliseUsage(usage).cacheWrite;
}
return totals;
}
export async function POST(request: NextRequest) {
let helperKey = 'ra-h';
try {
const {
messages = [],
openTabs = [],
activeTabId = null,
currentView = 'nodes',
sessionId,
traceId,
mode: requestedMode = 'easy',
apiKeys: rawApiKeys
} = await request.json();
const apiKeys: ApiKeyOverrides | undefined = rawApiKeys
? {
openai: typeof rawApiKeys.openai === 'string' && rawApiKeys.openai.trim().length > 0
? rawApiKeys.openai.trim()
: undefined,
anthropic: typeof rawApiKeys.anthropic === 'string' && rawApiKeys.anthropic.trim().length > 0
? rawApiKeys.anthropic.trim()
: undefined,
}
: undefined;
const mode: 'easy' | 'hard' = requestedMode === 'hard' ? 'hard' : 'easy';
helperKey = mode === 'hard' ? 'ra-h' : 'ra-h-easy';
const conversationTraceId = traceId || randomUUID();
RequestContext.set({
traceId: conversationTraceId,
openTabs,
activeTabId,
mode,
apiKeys,
});
// Filter messages to only valid roles
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sanitizedMessages = messages.filter((msg: any) =>
msg && ['user', 'assistant', 'system'].includes(msg.role)
);
const helperConfig = await AgentRegistry.orchestratorForMode(mode);
if (!helperConfig) {
throw new Error(`No orchestrator definition found for mode '${mode}'`);
}
helperKey = helperConfig.key || helperKey;
helperLogger.logUserMessage(helperKey, messages, openTabs, activeTabId);
const { blocks: systemBlocks, cacheHit } = await buildSystemPromptBlocks(
{ nodes: openTabs, activeNodeId: activeTabId },
helperKey
);
const systemPromptPreview = systemBlocks.map(b => b.text).join('\n').substring(0, 200) + '...';
helperLogger.logSystemPrompt(helperKey, systemPromptPreview, cacheHit);
console.log('🔧 [Prompt Caching] System blocks structure:', {
helperKey,
totalBlocks: systemBlocks.length,
cachedBlocks: systemBlocks.filter(b => b.cache_control).length,
blockLengths: systemBlocks.map((b, i) => ({
index: i,
length: b.text.length,
cached: !!b.cache_control
}))
});
const toolNames = helperConfig.availableTools?.length
? helperConfig.availableTools
: getDefaultToolNamesForRole(helperConfig.role);
const tools = getHelperTools(toolNames);
const modelId = helperConfig.model || 'anthropic/claude-sonnet-4.5';
const model = resolveModel(modelId, apiKeys);
const rawModelId = modelId.split('/')[1] || modelId;
const fullModelId = ANTHROPIC_MODEL_MAP[rawModelId] || rawModelId;
const isAnthropicModel = modelId.startsWith('anthropic/');
const isOpenAIModel = modelId.startsWith('openai/');
const toolsUsedInSession: string[] = [];
// Convert system blocks to messages with providerOptions for caching
const systemMessages = systemBlocks.map((block) => ({
role: 'system' as const,
content: block.text,
...(isAnthropicModel && block.cache_control ? {
providerOptions: {
anthropic: { cacheControl: block.cache_control }
}
} : {})
}));
const coreMessages = convertToCoreMessages(sanitizedMessages);
const allMessages = [...systemMessages, ...coreMessages];
// Debug logging (can be removed in production)
if (process.env.DEBUG_CACHE === 'true') {
console.log('🔍 [Debug] System messages with cache control:');
systemMessages.forEach((msg, i) => {
console.log(` Block ${i}:`, {
hasContent: !!msg.content,
contentLength: msg.content?.length || 0,
hasProviderOptions: !!msg.providerOptions,
cacheControl: msg.providerOptions?.anthropic?.cacheControl
});
});
}
const streamConfig = {
model,
messages: allMessages,
tools,
stopWhen: [],
maxSteps: 10,
...(isOpenAIModel ? { reasoning: { effort: 'light' as const } } : {}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onToolCall: ({ toolCall }: any) => {
helperLogger.logToolCall(helperKey, toolCall.toolName, toolCall.args);
if (!toolsUsedInSession.includes(toolCall.toolName)) {
toolsUsedInSession.push(toolCall.toolName);
}
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onToolResult: ({ toolCall, result }: any) => {
helperLogger.logToolResult(helperKey, toolCall.toolName, result);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onFinish: (result: any) => {
helperLogger.logAssistantResponse(helperKey, result.text);
// Debug logging (can be removed in production)
if (process.env.DEBUG_CACHE === 'true') {
console.log('🔍 [Debug] Full onFinish result keys:', Object.keys(result));
console.log('🔍 [Debug] Raw usage object:', JSON.stringify(result.usage, null, 2));
console.log('🔍 [Debug] Provider metadata:', JSON.stringify(result.providerMetadata, null, 2));
console.log('🔍 [Debug] Steps array length:', result.steps?.length || 0);
if (result.steps && result.steps.length > 0) {
result.steps.forEach((step: any, i: number) => {
console.log(`🔍 [Debug] Step ${i} usage:`, JSON.stringify(step.usage, null, 2));
console.log(`🔍 [Debug] Step ${i} providerMetadata:`, JSON.stringify(step.providerMetadata, null, 2));
});
}
}
const aggregatedUsage = isAnthropicModel
? (() => {
// Aggregate across ALL steps, not just final result
const totals = { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 };
if (result.steps && Array.isArray(result.steps)) {
result.steps.forEach((step: any) => {
const stepUsage = aggregateAnthropicUsage(step.usage, step.providerMetadata);
totals.inputTokens += stepUsage.inputTokens;
totals.outputTokens += stepUsage.outputTokens;
// Cache write only happens once (in first step), so take MAX not SUM
totals.cacheWriteTokens = Math.max(totals.cacheWriteTokens, stepUsage.cacheWriteTokens);
totals.cacheReadTokens += stepUsage.cacheReadTokens;
});
} else {
// Fallback to result-level usage if no steps
const resultUsage = aggregateAnthropicUsage(result.usage, result.providerMetadata);
totals.inputTokens = resultUsage.inputTokens;
totals.outputTokens = resultUsage.outputTokens;
totals.cacheWriteTokens = resultUsage.cacheWriteTokens;
totals.cacheReadTokens = resultUsage.cacheReadTokens;
}
return totals;
})()
: {
inputTokens: Number(result.usage?.inputTokens ?? result.usage?.promptTokens ?? 0),
outputTokens: Number(result.usage?.outputTokens ?? result.usage?.completionTokens ?? 0),
cacheWriteTokens: 0,
cacheReadTokens: 0,
};
const regular = aggregatedUsage.inputTokens || 0;
const cacheWrite = aggregatedUsage.cacheWriteTokens || 0;
const cacheRead = aggregatedUsage.cacheReadTokens || 0;
const outputTokens = aggregatedUsage.outputTokens || 0;
const total = regular + cacheWrite + cacheRead;
if (regular || cacheWrite || cacheRead || outputTokens) {
const savingsPercentage = total > 0 && cacheRead > 0 ? Math.round((cacheRead / total) * 100) : 0;
if (isAnthropicModel) {
const cacheStats: CacheStats = {
cacheCreationInputTokens: cacheWrite,
cacheReadInputTokens: cacheRead,
inputTokens: regular,
outputTokens,
savingsPercentage
};
console.log('\n📦 ═══════════════════════════════════════');
console.log('📦 ANTHROPIC PROMPT CACHE STATISTICS');
console.log('📦 ═══════════════════════════════════════');
console.log(`📦 Cache Write: ${cacheWrite.toLocaleString()} tokens ${cacheWrite > 0 ? '(NEW CACHE CREATED ✨)' : ''}`);
console.log(`📦 Cache Read: ${cacheRead.toLocaleString()} tokens ${cacheRead > 0 ? '(CACHE HIT 🎯)' : '(CACHE MISS ❌)'}`);
console.log(`📦 Regular: ${regular.toLocaleString()} tokens`);
console.log(`📦 Total Input: ${total.toLocaleString()} tokens`);
console.log(`📦 Output: ${cacheStats.outputTokens.toLocaleString()} tokens`);
if (cacheRead > 0) {
const costSavings = Math.round(((cacheWrite * 1.25 + regular * 1.0 + cacheRead * 0.1) / (total * 1.0)) * 100);
console.log(`📦 💰 Savings: ${savingsPercentage}% tokens, ~${100 - costSavings}% cost`);
}
console.log('📦 ═══════════════════════════════════════\n');
global.lastCacheStats = cacheStats;
}
const costResult = calculateCost({
inputTokens: regular,
outputTokens,
cacheWriteTokens: cacheWrite,
cacheReadTokens: cacheRead,
modelId: fullModelId,
});
usageData = {
inputTokens: regular,
outputTokens,
totalTokens: costResult.totalTokens,
cacheWriteTokens: cacheWrite,
cacheReadTokens: cacheRead,
cacheHit: cacheRead > 0,
cacheSavingsPct: savingsPercentage,
estimatedCostUsd: costResult.totalCostUsd,
modelUsed: fullModelId,
provider: isAnthropicModel ? 'anthropic' : 'openai',
toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined,
toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined,
traceId: conversationTraceId,
workflowKey: currentContext.workflowKey,
workflowNodeId: currentContext.workflowNodeId,
mode,
};
}
}
};
let usageData: UsageData | undefined;
const systemMessageText = systemBlocks.map(b => b.text).join('\n\n');
const currentContext = RequestContext.get();
const chatMetadata = {
helperName: helperKey,
openTabs,
activeTabId,
currentView,
sessionId: sessionId || `session_${Date.now()}`,
agentType: 'orchestrator' as const,
traceId: conversationTraceId,
mode,
modelUsed: fullModelId,
systemMessage: systemMessageText,
workflowKey: currentContext.workflowKey,
workflowNodeId: currentContext.workflowNodeId,
get usageData() {
return usageData;
},
};
const result = await streamText(withChatLogging(streamConfig, chatMetadata, messages));
return result.toUIMessageStreamResponse();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
console.error(`❌ [${helperKey}] Route error:`, {
error: errorMessage,
stack: errorStack,
timestamp: new Date().toISOString()
});
helperLogger.logError(helperKey, error instanceof Error ? error : String(error));
return new Response(
JSON.stringify({
error: errorMessage,
details: 'Check server logs for full error details'
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
);
}
}
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { AgentDelegationService } from '@/services/agents/delegation';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
try {
const { sessionId } = await params;
const delegation = AgentDelegationService.getBySessionId(sessionId);
if (!delegation) {
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
}
return NextResponse.json({ delegation });
} catch (error) {
console.error('Failed to fetch delegation:', error);
return NextResponse.json({ error: 'Failed to fetch delegation' }, { status: 500 });
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
try {
const { sessionId } = await params;
const body = await request.json();
const summary: string | undefined = body?.summary;
const status: string | undefined = body?.status;
if (!summary && !status) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 });
}
const normalizedStatus = status && ['queued', 'in_progress', 'completed', 'failed'].includes(status)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? (status as any)
: undefined;
const delegation = summary
? AgentDelegationService.completeDelegation(sessionId, summary, normalizedStatus ?? 'completed')
: AgentDelegationService.markInProgress(sessionId);
if (!delegation) {
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
}
return NextResponse.json({ delegation });
} catch (error) {
console.error('Failed to update delegation:', error);
return NextResponse.json({ error: 'Failed to update delegation' }, { status: 500 });
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
try {
const { sessionId } = await params;
const deleted = AgentDelegationService.deleteDelegation(sessionId);
if (!deleted) {
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete delegation:', error);
return NextResponse.json({ error: 'Failed to delete delegation' }, { status: 500 });
}
}
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { AgentDelegationService } from '@/services/agents/delegation';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
try {
const { sessionId } = await params;
const body = await request.json();
const summary: string | undefined = body?.summary;
const status: string | undefined = body?.status;
if (!summary) {
return NextResponse.json({ error: 'Summary is required' }, { status: 400 });
}
const normalizedStatus = status && ['queued', 'in_progress', 'completed', 'failed'].includes(status)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? (status as any)
: 'completed';
const delegation = AgentDelegationService.completeDelegation(sessionId, summary, normalizedStatus);
if (!delegation) {
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
}
return NextResponse.json({ delegation });
} catch (error) {
console.error('Failed to store delegation summary:', error);
return NextResponse.json({ error: 'Failed to store delegation summary' }, { status: 500 });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { AgentDelegationService } from '@/services/agents/delegation';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const statusFilter = searchParams.get('status');
const includeCompleted = searchParams.get('includeCompleted') === 'true'
|| statusFilter !== 'active';
const delegations = statusFilter === 'active'
? AgentDelegationService.listActive({ includeCompleted })
: AgentDelegationService.listRecent();
return NextResponse.json({ delegations });
} catch (error) {
console.error('Failed to list delegations:', error);
return NextResponse.json({ error: 'Failed to load delegations' }, { status: 500 });
}
}
+179
View File
@@ -0,0 +1,179 @@
import { NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const maxDuration = 900;
class DelegationStreamBroadcaster {
private connections = new Map<string, Set<ReadableStreamDefaultController>>();
private pendingMessages = new Map<string, any[]>();
private encoder = new TextEncoder();
private encode(message: any) {
return `data: ${JSON.stringify({ ...message, timestamp: Date.now() })}\n\n`;
}
private send(controller: ReadableStreamDefaultController, encoded: string) {
try {
controller.enqueue(this.encoder.encode(encoded));
return true;
} catch (error) {
console.log('[DelegationStream] Removing dead connection', error);
return false;
}
}
addConnection(sessionId: string, controller: ReadableStreamDefaultController) {
if (!this.connections.has(sessionId)) {
this.connections.set(sessionId, new Set());
}
this.connections.get(sessionId)!.add(controller);
console.log(`[DelegationStream] Connection added for ${sessionId}, total: ${this.connections.get(sessionId)!.size}`);
const backlog = this.pendingMessages.get(sessionId);
if (backlog && backlog.length > 0) {
console.log(`[DelegationStream] Flushing ${backlog.length} queued events for ${sessionId}`);
for (const message of backlog) {
const encoded = this.encode(message);
const delivered = this.send(controller, encoded);
if (!delivered) {
this.removeConnection(sessionId, controller);
break;
}
}
if ((this.connections.get(sessionId)?.size || 0) > 0) {
this.pendingMessages.delete(sessionId);
}
}
}
removeConnection(sessionId: string, controller: ReadableStreamDefaultController) {
const sessionConns = this.connections.get(sessionId);
if (sessionConns) {
sessionConns.delete(controller);
console.log(`[DelegationStream] Connection removed from ${sessionId}, remaining: ${sessionConns.size}`);
if (sessionConns.size === 0) {
this.connections.delete(sessionId);
}
}
}
broadcast(sessionId: string, message: any) {
const sessionConns = this.connections.get(sessionId);
if (!sessionConns || sessionConns.size === 0) {
const queue = this.pendingMessages.get(sessionId) ?? [];
queue.push(message);
// Prevent unbounded growth by keeping the latest 200 events
if (queue.length > 200) {
queue.splice(0, queue.length - 200);
}
this.pendingMessages.set(sessionId, queue);
console.log(`[DelegationStream] Queued event for ${sessionId}, pending=${queue.length}`);
return;
}
const encoded = this.encode(message);
let successCount = 0;
const staleControllers: ReadableStreamDefaultController[] = [];
for (const controller of sessionConns) {
if (this.send(controller, encoded)) {
successCount++;
} else {
staleControllers.push(controller);
}
}
if (staleControllers.length > 0) {
staleControllers.forEach((controller) => sessionConns.delete(controller));
}
console.log(`[DelegationStream] Broadcasted to ${successCount}/${sessionConns.size} connections for ${sessionId}`);
}
sendKeepAlive(sessionId: string) {
const sessionConns = this.connections.get(sessionId);
if (!sessionConns) return;
const ping = this.encoder.encode(`: keep-alive\n\n`);
for (const controller of sessionConns) {
try {
controller.enqueue(ping);
} catch {
sessionConns.delete(controller);
}
}
}
}
declare global {
// eslint-disable-next-line no-var
var delegationStreamBroadcaster: DelegationStreamBroadcaster | undefined;
}
export const delegationStreamBroadcaster =
globalThis.delegationStreamBroadcaster ?? new DelegationStreamBroadcaster();
if (typeof window === 'undefined') {
globalThis.delegationStreamBroadcaster = delegationStreamBroadcaster;
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const sessionId = searchParams.get('sessionId');
if (!sessionId) {
return new Response('Missing sessionId', { status: 400 });
}
const stream = new ReadableStream({
start(controller) {
const state = this as unknown as { cleanup?: () => void; abortHandler?: () => void };
delegationStreamBroadcaster.addConnection(sessionId, controller);
const encoder = new TextEncoder();
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'CONNECTION_ESTABLISHED', timestamp: Date.now() })}\n\n`));
const keepAliveInterval = setInterval(() => {
delegationStreamBroadcaster.sendKeepAlive(sessionId);
}, 30000);
const cleanup = () => {
clearInterval(keepAliveInterval);
delegationStreamBroadcaster.removeConnection(sessionId, controller);
state.cleanup = undefined;
};
const abortHandler = () => {
cleanup();
request.signal.removeEventListener('abort', abortHandler);
state.abortHandler = undefined;
};
request.signal.addEventListener('abort', abortHandler);
state.cleanup = cleanup;
state.abortHandler = abortHandler;
},
cancel() {
console.log(`[DelegationStream] Stream cancelled for ${sessionId}`);
const state = this as unknown as { cleanup?: () => void; abortHandler?: () => void };
if (state.abortHandler) {
request.signal.removeEventListener('abort', state.abortHandler);
state.abortHandler = undefined;
}
if (state.cleanup) {
state.cleanup();
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
const REALTIME_MODEL = process.env.OPENAI_REALTIME_MODEL || 'gpt-4o-realtime-preview-2024-12-17';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization');
if (!authHeader) {
return NextResponse.json({ error: 'Missing authorization header' }, { status: 401 });
}
const apiKey =
process.env.RAH_REALTIME_OPENAI_API_KEY ||
process.env.RAH_DELEGATE_OPENAI_API_KEY ||
process.env.OPENAI_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: 'Realtime OpenAI API key is not configured' }, { status: 500 });
}
const response = await fetch('https://api.openai.com/v1/realtime/sessions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: REALTIME_MODEL,
modalities: ['text'],
instructions: 'Provide high-accuracy streaming transcription only. Never speak responses.',
}),
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => null);
const message = errorPayload?.error?.message || response.statusText || 'Failed to create realtime session';
return NextResponse.json({ error: message }, { status: response.status });
}
const data = await response.json();
return NextResponse.json({
client_secret: data.client_secret,
expires_at: data.expires_at,
model: data.model ?? REALTIME_MODEL,
voice: data.voice,
id: data.id,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[realtime] Failed to mint ephemeral token:', message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import {
getAutoContextSettings,
setAutoContextEnabled,
} from '@/services/settings/autoContextSettings';
export const runtime = 'nodejs';
export async function GET() {
try {
const settings = getAutoContextSettings();
return NextResponse.json({ success: true, data: settings });
} catch (error) {
console.error('Failed to read auto-context settings:', error);
return NextResponse.json(
{ success: false, error: 'Unable to read auto-context settings' },
{ status: 500 }
);
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
if (!body || typeof body.autoContextEnabled !== 'boolean') {
return NextResponse.json(
{ success: false, error: 'autoContextEnabled boolean is required' },
{ status: 400 }
);
}
const updated = setAutoContextEnabled(body.autoContextEnabled);
return NextResponse.json({ success: true, data: updated });
} catch (error) {
console.error('Failed to update auto-context settings:', error);
return NextResponse.json(
{ success: false, error: 'Unable to update auto-context settings' },
{ status: 500 }
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import os from 'os';
import path from 'path';
export const runtime = 'nodejs';
const STATUS_PATH = path.join(
os.homedir(),
'Library',
'Application Support',
'RA-H',
'config',
'mcp-status.json'
);
export async function GET() {
try {
if (!fs.existsSync(STATUS_PATH)) {
return NextResponse.json({
enabled: false,
port: null,
url: null,
last_updated: null,
target_base_url: null
});
}
const raw = fs.readFileSync(STATUS_PATH, 'utf-8');
const parsed = JSON.parse(raw);
return NextResponse.json(parsed);
} catch (error) {
console.error('Failed to read MCP status file:', error);
return NextResponse.json(
{
enabled: false,
port: null,
url: null,
last_updated: null,
target_base_url: null,
error: 'Unable to read MCP status'
},
{ status: 500 }
);
}
}
+28
View File
@@ -0,0 +1,28 @@
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' }
});
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import { TOOL_GROUP_ASSIGNMENTS, TOOL_GROUPS } from '@/tools/infrastructure/groups';
import { getHelperTools } from '@/tools/infrastructure/registry';
export async function GET() {
try {
const grouped: Record<string, { name: string; description: string }[]> = {
core: [],
orchestration: [],
execution: [],
};
Object.entries(TOOL_GROUP_ASSIGNMENTS).forEach(([toolName, groupId]) => {
const tools = getHelperTools([toolName]);
const tool = tools[toolName];
if (tool) {
grouped[groupId]?.push({
name: toolName,
description: tool.description || 'No description available',
});
}
});
return NextResponse.json({
success: true,
data: {
groups: TOOL_GROUPS,
tools: grouped,
},
});
} catch (error) {
console.error('Error fetching tools:', error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch tools' },
{ status: 500 }
);
}
}
+101
View File
@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server';
import { randomUUID } from 'crypto';
import { recordVoiceUsage } from '@/services/voice/usageLogger';
const OPENAI_TTS_MODEL = process.env.RAH_TTS_MODEL || 'gpt-4o-mini-tts';
const DEFAULT_TTS_VOICE = process.env.RAH_TTS_VOICE || 'ash';
const DEFAULT_TTS_COST_PER_1K_CHAR_USD = 0.015;
const TTS_COST_PER_1K_CHAR_USD = (() => {
const raw = process.env.RAH_TTS_COST_PER_1K_CHAR_USD;
if (!raw) return DEFAULT_TTS_COST_PER_1K_CHAR_USD;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTS_COST_PER_1K_CHAR_USD;
})();
function estimateTtsCost(charCount: number) {
const cost = (charCount / 1000) * TTS_COST_PER_1K_CHAR_USD;
return Number.isFinite(cost) ? parseFloat(cost.toFixed(6)) : 0;
}
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest) {
try {
const apiKey = process.env.RAH_VOICE_OPENAI_API_KEY || process.env.RAH_DELEGATE_OPENAI_API_KEY || process.env.OPENAI_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: 'OpenAI API key is not configured' }, { status: 500 });
}
const body = await request.json().catch(() => null);
const text = typeof body?.text === 'string' ? body.text.trim() : '';
const voice = typeof body?.voice === 'string' && body.voice.trim().length > 0 ? body.voice.trim() : DEFAULT_TTS_VOICE;
const helperName = typeof body?.helper === 'string' && body.helper.trim().length > 0 ? body.helper.trim() : null;
const sessionId = typeof body?.sessionId === 'string' && body.sessionId.trim().length > 0 ? body.sessionId.trim() : null;
const messageId = typeof body?.messageId === 'string' && body.messageId.trim().length > 0 ? body.messageId.trim() : null;
const providedRequestId = typeof body?.requestId === 'string' && body.requestId.trim().length > 0 ? body.requestId.trim() : null;
const voiceRequestId = providedRequestId || randomUUID();
if (!text) {
return NextResponse.json({ error: 'Text is required for TTS' }, { status: 400 });
}
const requestStartedAt = Date.now();
const response = await fetch('https://api.openai.com/v1/audio/speech', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: OPENAI_TTS_MODEL,
voice,
input: text,
format: 'mp3',
}),
});
if (!response.ok || !response.body) {
const errorPayload = await response.json().catch(() => null);
const message = errorPayload?.error?.message || response.statusText || 'Failed to synthesize audio';
return NextResponse.json({ error: message }, { status: response.status || 500 });
}
const durationMs = Date.now() - requestStartedAt;
const charCount = [...text].length;
const estimatedCostUsd = estimateTtsCost(charCount);
const textPreview =
text.length > 240 ? `${text.slice(0, 237)}...` : text;
try {
recordVoiceUsage({
sessionId,
helperName,
requestId: voiceRequestId,
messageId,
voice,
model: OPENAI_TTS_MODEL,
charCount,
costUsd: estimatedCostUsd,
durationMs,
textPreview,
});
} catch (loggingError) {
console.error('[voice/tts] failed to record usage', loggingError);
}
const headers = new Headers();
headers.set('Content-Type', response.headers.get('Content-Type') || 'audio/mpeg');
headers.set('Cache-Control', 'no-cache');
headers.set('X-Voice-Request-Id', voiceRequestId);
return new Response(response.body, {
status: 200,
headers,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('[voice/tts] failed to synthesize:', message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { WorkflowRegistry } from '@/services/workflows/registry';
export async function GET() {
try {
const workflows = await WorkflowRegistry.getAllWorkflows();
return NextResponse.json({
success: true,
data: workflows,
});
} catch (error) {
console.error('Error fetching workflows:', error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch workflows' },
{ status: 500 }
);
}
}
+132
View File
@@ -0,0 +1,132 @@
/* Import clean, modern fonts */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&display=swap');
/* Geist Sans - Modern font optimized for reading/writing applications */
@font-face {
font-family: 'Geist';
src: url('https://cdn.jsdelivr.net/npm/geist@1.3.0/dist/fonts/geist-sans/Geist-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('https://cdn.jsdelivr.net/npm/geist@1.3.0/dist/fonts/geist-sans/Geist-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('https://cdn.jsdelivr.net/npm/geist@1.3.0/dist/fonts/geist-sans/Geist-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('https://cdn.jsdelivr.net/npm/geist@1.3.0/dist/fonts/geist-sans/Geist-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Modern Clean Design System - RA-H */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
overflow: hidden;
background-color: #0a0a0a;
color: #e5e5e5;
/* Geist Sans for optimal reading/writing experience */
font-family: 'Geist', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
line-height: 1.5;
letter-spacing: -0.011em; /* Optimized letter spacing for Geist */
font-weight: 400;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Monospace font class for technical elements */
.font-mono {
font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace;
font-feature-settings: 'liga' 1, 'calt' 1;
}
/* Scrollbar styling for terminal theme */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #0a0a0a;
}
::-webkit-scrollbar-thumb {
background: #353535;
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: #4a4a4a;
}
/* Utility classes */
.border-divider {
border-color: #1a1a1a;
}
.text-muted {
color: #6b6b6b;
}
.text-primary {
color: #e5e5e5;
}
.bg-hover:hover {
background-color: #151515;
}
/* Terminal animations */
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.2);
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeIn {
to { opacity: 1; }
}
@keyframes loadingPulse {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
+18
View File
@@ -0,0 +1,18 @@
import './globals.css';
export const metadata = {
title: 'RA-H Open Source',
description: 'Local-first research workspace with a BYO-key AI orchestrator',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
import ThreePanelLayout from '@/components/layout/ThreePanelLayout';
import { LocalKeyGate } from '@/components/auth/LocalKeyGate';
export default function Home() {
return (
<LocalKeyGate>
<ThreePanelLayout />
</LocalKeyGate>
);
}