Initial commit: RA-H Open Source Edition

Local-first knowledge management system with BYO API keys.

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

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

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

MIT License
This commit is contained in:
“BeeRad”
2025-12-15 16:14:28 +11:00
commit 733d1c3407
226 changed files with 46231 additions and 0 deletions
+465
View File
@@ -0,0 +1,465 @@
import { NextRequest } from 'next/server';
import { streamText, convertToCoreMessages } from 'ai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai';
import { getHelperTools, getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
import { buildSystemPromptBlocks } from '@/services/helpers/contextBuilder';
import { helperLogger } from '@/services/helpers/logger';
import { withChatLogging } from '@/services/chat/middleware';
import { AgentRegistry } from '@/services/agents/registry';
import { calculateCost } from '@/services/analytics/pricing';
import { UsageData } from '@/types/analytics';
import type { CacheStats } from '@/types/prompts';
import { randomUUID } from 'crypto';
import { RequestContext } from '@/services/context/requestContext';
import { isLocalMode } from '@/config/runtime';
export const maxDuration = 900; // 15 minutes (for workflows)
if (isLocalMode()) {
// TODO: add any special local-mode setup if needed later
}
const ANTHROPIC_MODEL_MAP: Record<string, string> = {
'claude-sonnet-4.5': 'claude-sonnet-4-5-20250929',
'claude-3-5-sonnet': 'claude-3-5-sonnet-20241022',
};
type ApiKeyOverrides = {
openai?: string;
anthropic?: string;
};
function resolveModel(modelId: string, apiKeys?: ApiKeyOverrides) {
if (modelId.startsWith('anthropic/')) {
const rawName = modelId.split('/')[1];
const mapped = ANTHROPIC_MODEL_MAP[rawName] || rawName;
const orchestratorKey =
apiKeys?.anthropic ||
process.env.RAH_ORCHESTRATOR_ANTHROPIC_API_KEY ||
process.env.ANTHROPIC_API_KEY;
if (!orchestratorKey) {
throw new Error('RAH_ORCHESTRATOR_ANTHROPIC_API_KEY (or ANTHROPIC_API_KEY) is not set.');
}
const provider = createAnthropic({
apiKey: orchestratorKey,
headers: {
'anthropic-beta': 'prompt-caching-2024-07-31',
},
});
return provider(mapped);
}
if (modelId.startsWith('openai/')) {
const name = modelId.split('/')[1];
const delegateKey =
apiKeys?.openai ||
process.env.RAH_DELEGATE_OPENAI_API_KEY ||
process.env.OPENAI_API_KEY;
if (!delegateKey) {
throw new Error('RAH_DELEGATE_OPENAI_API_KEY (or OPENAI_API_KEY) is not set.');
}
const provider = createOpenAI({ apiKey: delegateKey });
return provider(name);
}
throw new Error(`Unsupported model id: ${modelId}`);
}
// Global cache stats storage for monitoring
declare global {
// eslint-disable-next-line no-var
var lastCacheStats: CacheStats | undefined;
}
type AnthropicUsageLike = {
inputTokens?: number;
outputTokens?: number;
promptTokens?: number;
completionTokens?: number;
cacheCreationInputTokens?: number;
cachedInputTokens?: number;
totalTokens?: number;
cache_write_input_tokens?: number;
cache_read_input_tokens?: number;
cacheWriteInputTokens?: number;
cacheReadInputTokens?: number;
};
function normaliseUsage(entry: AnthropicUsageLike | undefined | null) {
if (!entry) {
return { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
}
const toNumber = (value: unknown): number => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
};
const input = toNumber(entry.inputTokens ?? entry.promptTokens ?? (entry as any).input_tokens ?? 0);
const output = toNumber(entry.outputTokens ?? entry.completionTokens ?? (entry as any).output_tokens ?? 0);
const cacheWrite = toNumber(
entry.cacheCreationInputTokens ??
(entry as any).cache_creation_input_tokens ??
entry.cacheWriteInputTokens ??
entry.cache_write_input_tokens ??
0
);
const cacheRead = toNumber(
entry.cachedInputTokens ??
entry.cacheReadInputTokens ??
(entry as any).cache_read_input_tokens ??
0
);
return { input, output, cacheWrite, cacheRead };
}
function aggregateAnthropicUsage(usage: AnthropicUsageLike | undefined, providerMetadata: any) {
const totals = { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 };
let dataSources = 0;
const add = (entry: AnthropicUsageLike | undefined | null) => {
if (!entry) return;
const { input, output, cacheWrite, cacheRead } = normaliseUsage(entry);
if (input || output || cacheWrite || cacheRead) {
totals.inputTokens += input;
totals.outputTokens += output;
totals.cacheWriteTokens += cacheWrite;
totals.cacheReadTokens += cacheRead;
dataSources += 1;
}
};
const anthropicMeta = providerMetadata?.anthropic;
if (anthropicMeta) {
if (Array.isArray(anthropicMeta.requests)) {
anthropicMeta.requests.forEach((req: any) => {
add(req?.usage ?? req);
});
}
if (anthropicMeta.response?.usage) {
add(anthropicMeta.response.usage);
}
if (anthropicMeta.usage && dataSources === 0) {
add(anthropicMeta.usage);
}
}
if (dataSources === 0) {
add(usage);
}
// Fallback to usage totals if metadata did not provide cache info
if (totals.cacheReadTokens === 0 && usage?.cachedInputTokens) {
totals.cacheReadTokens = normaliseUsage(usage).cacheRead;
}
if (totals.cacheWriteTokens === 0 && usage?.cacheCreationInputTokens) {
totals.cacheWriteTokens = normaliseUsage(usage).cacheWrite;
}
return totals;
}
export async function POST(request: NextRequest) {
let helperKey = 'ra-h';
try {
const {
messages = [],
openTabs = [],
activeTabId = null,
currentView = 'nodes',
sessionId,
traceId,
mode: requestedMode = 'easy',
apiKeys: rawApiKeys
} = await request.json();
const apiKeys: ApiKeyOverrides | undefined = rawApiKeys
? {
openai: typeof rawApiKeys.openai === 'string' && rawApiKeys.openai.trim().length > 0
? rawApiKeys.openai.trim()
: undefined,
anthropic: typeof rawApiKeys.anthropic === 'string' && rawApiKeys.anthropic.trim().length > 0
? rawApiKeys.anthropic.trim()
: undefined,
}
: undefined;
const mode: 'easy' | 'hard' = requestedMode === 'hard' ? 'hard' : 'easy';
helperKey = mode === 'hard' ? 'ra-h' : 'ra-h-easy';
const conversationTraceId = traceId || randomUUID();
RequestContext.set({
traceId: conversationTraceId,
openTabs,
activeTabId,
mode,
apiKeys,
});
// Filter messages to only valid roles
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sanitizedMessages = messages.filter((msg: any) =>
msg && ['user', 'assistant', 'system'].includes(msg.role)
);
const helperConfig = await AgentRegistry.orchestratorForMode(mode);
if (!helperConfig) {
throw new Error(`No orchestrator definition found for mode '${mode}'`);
}
helperKey = helperConfig.key || helperKey;
helperLogger.logUserMessage(helperKey, messages, openTabs, activeTabId);
const { blocks: systemBlocks, cacheHit } = await buildSystemPromptBlocks(
{ nodes: openTabs, activeNodeId: activeTabId },
helperKey
);
const systemPromptPreview = systemBlocks.map(b => b.text).join('\n').substring(0, 200) + '...';
helperLogger.logSystemPrompt(helperKey, systemPromptPreview, cacheHit);
console.log('🔧 [Prompt Caching] System blocks structure:', {
helperKey,
totalBlocks: systemBlocks.length,
cachedBlocks: systemBlocks.filter(b => b.cache_control).length,
blockLengths: systemBlocks.map((b, i) => ({
index: i,
length: b.text.length,
cached: !!b.cache_control
}))
});
const toolNames = helperConfig.availableTools?.length
? helperConfig.availableTools
: getDefaultToolNamesForRole(helperConfig.role);
const tools = getHelperTools(toolNames);
const modelId = helperConfig.model || 'anthropic/claude-sonnet-4.5';
const model = resolveModel(modelId, apiKeys);
const rawModelId = modelId.split('/')[1] || modelId;
const fullModelId = ANTHROPIC_MODEL_MAP[rawModelId] || rawModelId;
const isAnthropicModel = modelId.startsWith('anthropic/');
const isOpenAIModel = modelId.startsWith('openai/');
const toolsUsedInSession: string[] = [];
// Convert system blocks to messages with providerOptions for caching
const systemMessages = systemBlocks.map((block) => ({
role: 'system' as const,
content: block.text,
...(isAnthropicModel && block.cache_control ? {
providerOptions: {
anthropic: { cacheControl: block.cache_control }
}
} : {})
}));
const coreMessages = convertToCoreMessages(sanitizedMessages);
const allMessages = [...systemMessages, ...coreMessages];
// Debug logging (can be removed in production)
if (process.env.DEBUG_CACHE === 'true') {
console.log('🔍 [Debug] System messages with cache control:');
systemMessages.forEach((msg, i) => {
console.log(` Block ${i}:`, {
hasContent: !!msg.content,
contentLength: msg.content?.length || 0,
hasProviderOptions: !!msg.providerOptions,
cacheControl: msg.providerOptions?.anthropic?.cacheControl
});
});
}
const streamConfig = {
model,
messages: allMessages,
tools,
stopWhen: [],
maxSteps: 10,
...(isOpenAIModel ? { reasoning: { effort: 'light' as const } } : {}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onToolCall: ({ toolCall }: any) => {
helperLogger.logToolCall(helperKey, toolCall.toolName, toolCall.args);
if (!toolsUsedInSession.includes(toolCall.toolName)) {
toolsUsedInSession.push(toolCall.toolName);
}
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onToolResult: ({ toolCall, result }: any) => {
helperLogger.logToolResult(helperKey, toolCall.toolName, result);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onFinish: (result: any) => {
helperLogger.logAssistantResponse(helperKey, result.text);
// Debug logging (can be removed in production)
if (process.env.DEBUG_CACHE === 'true') {
console.log('🔍 [Debug] Full onFinish result keys:', Object.keys(result));
console.log('🔍 [Debug] Raw usage object:', JSON.stringify(result.usage, null, 2));
console.log('🔍 [Debug] Provider metadata:', JSON.stringify(result.providerMetadata, null, 2));
console.log('🔍 [Debug] Steps array length:', result.steps?.length || 0);
if (result.steps && result.steps.length > 0) {
result.steps.forEach((step: any, i: number) => {
console.log(`🔍 [Debug] Step ${i} usage:`, JSON.stringify(step.usage, null, 2));
console.log(`🔍 [Debug] Step ${i} providerMetadata:`, JSON.stringify(step.providerMetadata, null, 2));
});
}
}
const aggregatedUsage = isAnthropicModel
? (() => {
// Aggregate across ALL steps, not just final result
const totals = { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 };
if (result.steps && Array.isArray(result.steps)) {
result.steps.forEach((step: any) => {
const stepUsage = aggregateAnthropicUsage(step.usage, step.providerMetadata);
totals.inputTokens += stepUsage.inputTokens;
totals.outputTokens += stepUsage.outputTokens;
// Cache write only happens once (in first step), so take MAX not SUM
totals.cacheWriteTokens = Math.max(totals.cacheWriteTokens, stepUsage.cacheWriteTokens);
totals.cacheReadTokens += stepUsage.cacheReadTokens;
});
} else {
// Fallback to result-level usage if no steps
const resultUsage = aggregateAnthropicUsage(result.usage, result.providerMetadata);
totals.inputTokens = resultUsage.inputTokens;
totals.outputTokens = resultUsage.outputTokens;
totals.cacheWriteTokens = resultUsage.cacheWriteTokens;
totals.cacheReadTokens = resultUsage.cacheReadTokens;
}
return totals;
})()
: {
inputTokens: Number(result.usage?.inputTokens ?? result.usage?.promptTokens ?? 0),
outputTokens: Number(result.usage?.outputTokens ?? result.usage?.completionTokens ?? 0),
cacheWriteTokens: 0,
cacheReadTokens: 0,
};
const regular = aggregatedUsage.inputTokens || 0;
const cacheWrite = aggregatedUsage.cacheWriteTokens || 0;
const cacheRead = aggregatedUsage.cacheReadTokens || 0;
const outputTokens = aggregatedUsage.outputTokens || 0;
const total = regular + cacheWrite + cacheRead;
if (regular || cacheWrite || cacheRead || outputTokens) {
const savingsPercentage = total > 0 && cacheRead > 0 ? Math.round((cacheRead / total) * 100) : 0;
if (isAnthropicModel) {
const cacheStats: CacheStats = {
cacheCreationInputTokens: cacheWrite,
cacheReadInputTokens: cacheRead,
inputTokens: regular,
outputTokens,
savingsPercentage
};
console.log('\n📦 ═══════════════════════════════════════');
console.log('📦 ANTHROPIC PROMPT CACHE STATISTICS');
console.log('📦 ═══════════════════════════════════════');
console.log(`📦 Cache Write: ${cacheWrite.toLocaleString()} tokens ${cacheWrite > 0 ? '(NEW CACHE CREATED ✨)' : ''}`);
console.log(`📦 Cache Read: ${cacheRead.toLocaleString()} tokens ${cacheRead > 0 ? '(CACHE HIT 🎯)' : '(CACHE MISS ❌)'}`);
console.log(`📦 Regular: ${regular.toLocaleString()} tokens`);
console.log(`📦 Total Input: ${total.toLocaleString()} tokens`);
console.log(`📦 Output: ${cacheStats.outputTokens.toLocaleString()} tokens`);
if (cacheRead > 0) {
const costSavings = Math.round(((cacheWrite * 1.25 + regular * 1.0 + cacheRead * 0.1) / (total * 1.0)) * 100);
console.log(`📦 💰 Savings: ${savingsPercentage}% tokens, ~${100 - costSavings}% cost`);
}
console.log('📦 ═══════════════════════════════════════\n');
global.lastCacheStats = cacheStats;
}
const costResult = calculateCost({
inputTokens: regular,
outputTokens,
cacheWriteTokens: cacheWrite,
cacheReadTokens: cacheRead,
modelId: fullModelId,
});
usageData = {
inputTokens: regular,
outputTokens,
totalTokens: costResult.totalTokens,
cacheWriteTokens: cacheWrite,
cacheReadTokens: cacheRead,
cacheHit: cacheRead > 0,
cacheSavingsPct: savingsPercentage,
estimatedCostUsd: costResult.totalCostUsd,
modelUsed: fullModelId,
provider: isAnthropicModel ? 'anthropic' : 'openai',
toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined,
toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined,
traceId: conversationTraceId,
workflowKey: currentContext.workflowKey,
workflowNodeId: currentContext.workflowNodeId,
mode,
};
}
}
};
let usageData: UsageData | undefined;
const systemMessageText = systemBlocks.map(b => b.text).join('\n\n');
const currentContext = RequestContext.get();
const chatMetadata = {
helperName: helperKey,
openTabs,
activeTabId,
currentView,
sessionId: sessionId || `session_${Date.now()}`,
agentType: 'orchestrator' as const,
traceId: conversationTraceId,
mode,
modelUsed: fullModelId,
systemMessage: systemMessageText,
workflowKey: currentContext.workflowKey,
workflowNodeId: currentContext.workflowNodeId,
get usageData() {
return usageData;
},
};
const result = await streamText(withChatLogging(streamConfig, chatMetadata, messages));
return result.toUIMessageStreamResponse();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
console.error(`❌ [${helperKey}] Route error:`, {
error: errorMessage,
stack: errorStack,
timestamp: new Date().toISOString()
});
helperLogger.logError(helperKey, error instanceof Error ? error : String(error));
return new Response(
JSON.stringify({
error: errorMessage,
details: 'Check server logs for full error details'
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
);
}
}
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { AgentDelegationService } from '@/services/agents/delegation';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
try {
const { sessionId } = await params;
const delegation = AgentDelegationService.getBySessionId(sessionId);
if (!delegation) {
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
}
return NextResponse.json({ delegation });
} catch (error) {
console.error('Failed to fetch delegation:', error);
return NextResponse.json({ error: 'Failed to fetch delegation' }, { status: 500 });
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
try {
const { sessionId } = await params;
const body = await request.json();
const summary: string | undefined = body?.summary;
const status: string | undefined = body?.status;
if (!summary && !status) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 });
}
const normalizedStatus = status && ['queued', 'in_progress', 'completed', 'failed'].includes(status)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? (status as any)
: undefined;
const delegation = summary
? AgentDelegationService.completeDelegation(sessionId, summary, normalizedStatus ?? 'completed')
: AgentDelegationService.markInProgress(sessionId);
if (!delegation) {
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
}
return NextResponse.json({ delegation });
} catch (error) {
console.error('Failed to update delegation:', error);
return NextResponse.json({ error: 'Failed to update delegation' }, { status: 500 });
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
try {
const { sessionId } = await params;
const deleted = AgentDelegationService.deleteDelegation(sessionId);
if (!deleted) {
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete delegation:', error);
return NextResponse.json({ error: 'Failed to delete delegation' }, { status: 500 });
}
}
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { AgentDelegationService } from '@/services/agents/delegation';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
try {
const { sessionId } = await params;
const body = await request.json();
const summary: string | undefined = body?.summary;
const status: string | undefined = body?.status;
if (!summary) {
return NextResponse.json({ error: 'Summary is required' }, { status: 400 });
}
const normalizedStatus = status && ['queued', 'in_progress', 'completed', 'failed'].includes(status)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? (status as any)
: 'completed';
const delegation = AgentDelegationService.completeDelegation(sessionId, summary, normalizedStatus);
if (!delegation) {
return NextResponse.json({ error: 'Delegation not found' }, { status: 404 });
}
return NextResponse.json({ delegation });
} catch (error) {
console.error('Failed to store delegation summary:', error);
return NextResponse.json({ error: 'Failed to store delegation summary' }, { status: 500 });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { AgentDelegationService } from '@/services/agents/delegation';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const statusFilter = searchParams.get('status');
const includeCompleted = searchParams.get('includeCompleted') === 'true'
|| statusFilter !== 'active';
const delegations = statusFilter === 'active'
? AgentDelegationService.listActive({ includeCompleted })
: AgentDelegationService.listRecent();
return NextResponse.json({ delegations });
} catch (error) {
console.error('Failed to list delegations:', error);
return NextResponse.json({ error: 'Failed to load delegations' }, { status: 500 });
}
}
+179
View File
@@ -0,0 +1,179 @@
import { NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const maxDuration = 900;
class DelegationStreamBroadcaster {
private connections = new Map<string, Set<ReadableStreamDefaultController>>();
private pendingMessages = new Map<string, any[]>();
private encoder = new TextEncoder();
private encode(message: any) {
return `data: ${JSON.stringify({ ...message, timestamp: Date.now() })}\n\n`;
}
private send(controller: ReadableStreamDefaultController, encoded: string) {
try {
controller.enqueue(this.encoder.encode(encoded));
return true;
} catch (error) {
console.log('[DelegationStream] Removing dead connection', error);
return false;
}
}
addConnection(sessionId: string, controller: ReadableStreamDefaultController) {
if (!this.connections.has(sessionId)) {
this.connections.set(sessionId, new Set());
}
this.connections.get(sessionId)!.add(controller);
console.log(`[DelegationStream] Connection added for ${sessionId}, total: ${this.connections.get(sessionId)!.size}`);
const backlog = this.pendingMessages.get(sessionId);
if (backlog && backlog.length > 0) {
console.log(`[DelegationStream] Flushing ${backlog.length} queued events for ${sessionId}`);
for (const message of backlog) {
const encoded = this.encode(message);
const delivered = this.send(controller, encoded);
if (!delivered) {
this.removeConnection(sessionId, controller);
break;
}
}
if ((this.connections.get(sessionId)?.size || 0) > 0) {
this.pendingMessages.delete(sessionId);
}
}
}
removeConnection(sessionId: string, controller: ReadableStreamDefaultController) {
const sessionConns = this.connections.get(sessionId);
if (sessionConns) {
sessionConns.delete(controller);
console.log(`[DelegationStream] Connection removed from ${sessionId}, remaining: ${sessionConns.size}`);
if (sessionConns.size === 0) {
this.connections.delete(sessionId);
}
}
}
broadcast(sessionId: string, message: any) {
const sessionConns = this.connections.get(sessionId);
if (!sessionConns || sessionConns.size === 0) {
const queue = this.pendingMessages.get(sessionId) ?? [];
queue.push(message);
// Prevent unbounded growth by keeping the latest 200 events
if (queue.length > 200) {
queue.splice(0, queue.length - 200);
}
this.pendingMessages.set(sessionId, queue);
console.log(`[DelegationStream] Queued event for ${sessionId}, pending=${queue.length}`);
return;
}
const encoded = this.encode(message);
let successCount = 0;
const staleControllers: ReadableStreamDefaultController[] = [];
for (const controller of sessionConns) {
if (this.send(controller, encoded)) {
successCount++;
} else {
staleControllers.push(controller);
}
}
if (staleControllers.length > 0) {
staleControllers.forEach((controller) => sessionConns.delete(controller));
}
console.log(`[DelegationStream] Broadcasted to ${successCount}/${sessionConns.size} connections for ${sessionId}`);
}
sendKeepAlive(sessionId: string) {
const sessionConns = this.connections.get(sessionId);
if (!sessionConns) return;
const ping = this.encoder.encode(`: keep-alive\n\n`);
for (const controller of sessionConns) {
try {
controller.enqueue(ping);
} catch {
sessionConns.delete(controller);
}
}
}
}
declare global {
// eslint-disable-next-line no-var
var delegationStreamBroadcaster: DelegationStreamBroadcaster | undefined;
}
export const delegationStreamBroadcaster =
globalThis.delegationStreamBroadcaster ?? new DelegationStreamBroadcaster();
if (typeof window === 'undefined') {
globalThis.delegationStreamBroadcaster = delegationStreamBroadcaster;
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const sessionId = searchParams.get('sessionId');
if (!sessionId) {
return new Response('Missing sessionId', { status: 400 });
}
const stream = new ReadableStream({
start(controller) {
const state = this as unknown as { cleanup?: () => void; abortHandler?: () => void };
delegationStreamBroadcaster.addConnection(sessionId, controller);
const encoder = new TextEncoder();
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'CONNECTION_ESTABLISHED', timestamp: Date.now() })}\n\n`));
const keepAliveInterval = setInterval(() => {
delegationStreamBroadcaster.sendKeepAlive(sessionId);
}, 30000);
const cleanup = () => {
clearInterval(keepAliveInterval);
delegationStreamBroadcaster.removeConnection(sessionId, controller);
state.cleanup = undefined;
};
const abortHandler = () => {
cleanup();
request.signal.removeEventListener('abort', abortHandler);
state.abortHandler = undefined;
};
request.signal.addEventListener('abort', abortHandler);
state.cleanup = cleanup;
state.abortHandler = abortHandler;
},
cancel() {
console.log(`[DelegationStream] Stream cancelled for ${sessionId}`);
const state = this as unknown as { cleanup?: () => void; abortHandler?: () => void };
if (state.abortHandler) {
request.signal.removeEventListener('abort', state.abortHandler);
state.abortHandler = undefined;
}
if (state.cleanup) {
state.cleanup();
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
}