Initial commit: RA-H Open Source Edition
Local-first knowledge management system with BYO API keys. Features: - 3-panel UI (Nodes | Focus | Helpers) - SQLite + sqlite-vec for vector search - Agent system (Easy/Hard mode orchestrators) - Content extraction (YouTube, PDF, web) - Integrate workflow for connection discovery - Dimension system with auto-assignment Tech stack: - Next.js 15 + TypeScript + Tailwind CSS - Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK Setup: npm install && npm rebuild better-sqlite3 scripts/dev/bootstrap-local.sh npm run dev MIT License
This commit is contained in:
@@ -0,0 +1,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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}))
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user