diff --git a/.gitignore b/.gitignore index 98fe346e..08336315 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,9 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +# Root-only: unanchored `lib/` would ignore Next.js `ui/lib/` +/lib/ +/lib64/ parts/ sdist/ var/ diff --git a/ui/__tests__/lib/api-client.test.ts b/ui/__tests__/lib/api-client.test.ts new file mode 100644 index 00000000..e0c8dcc7 --- /dev/null +++ b/ui/__tests__/lib/api-client.test.ts @@ -0,0 +1,28 @@ +import { createRun, listRuns, getSettings } from '@/lib/api-client' + +global.fetch = jest.fn() + +beforeEach(() => jest.clearAllMocks()) + +test('createRun POSTs to /api/runs and returns run summary', async () => { + const mockRun = { id: 'abc123', ticker: 'NVDA', status: 'queued' } + ;(fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockRun, + }) + const result = await createRun({ + ticker: 'NVDA', date: '2024-05-10', + llm_provider: 'openai', deep_think_llm: 'gpt-5.2', + quick_think_llm: 'gpt-5-mini', max_debate_rounds: 1, + max_risk_discuss_rounds: 1, + }) + expect(fetch).toHaveBeenCalledWith('/api/runs', expect.objectContaining({ method: 'POST' })) + expect(result.id).toBe('abc123') +}) + +test('listRuns GETs /api/runs', async () => { + ;(fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => [] }) + const result = await listRuns() + expect(fetch).toHaveBeenCalledWith('/api/runs') + expect(Array.isArray(result)).toBe(true) +}) diff --git a/ui/lib/api-client.ts b/ui/lib/api-client.ts new file mode 100644 index 00000000..b8074e64 --- /dev/null +++ b/ui/lib/api-client.ts @@ -0,0 +1,33 @@ +import type { RunConfig, RunSummary } from './types/run' +import type { Settings } from './types/settings' + +const API = process.env.NEXT_PUBLIC_API_URL ?? '' + +async function apiFetch(path: string, init?: RequestInit): Promise { + const res = init + ? await fetch(`${API}${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...init, + }) + : await fetch(`${API}${path}`) + if (!res.ok) throw new Error(`API error ${res.status}: ${path}`) + return res.json() as Promise +} + +export const createRun = (config: RunConfig): Promise => + apiFetch('/api/runs', { method: 'POST', body: JSON.stringify(config) }) + +export const listRuns = (): Promise => + apiFetch('/api/runs') + +export const getRun = (id: string): Promise => + apiFetch(`/api/runs/${id}`) + +export const getSettings = (): Promise => + apiFetch('/api/settings') + +export const updateSettings = (settings: Settings): Promise => + apiFetch('/api/settings', { method: 'PUT', body: JSON.stringify(settings) }) + +export const getRunStreamUrl = (id: string): string => + `${process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'}/api/runs/${id}/stream` diff --git a/ui/lib/sse.ts b/ui/lib/sse.ts new file mode 100644 index 00000000..dc700d13 --- /dev/null +++ b/ui/lib/sse.ts @@ -0,0 +1,42 @@ +export type SSEHandlers = { + onAgentStart?: (data: { step: string; turn: number }) => void + onAgentComplete?: (data: { step: string; turn: number; report: string }) => void + onRunComplete?: (data: { decision: string; run_id: string }) => void + onRunError?: (data: { message: string }) => void + onOpen?: () => void +} + +export function createSSEConnection(url: string, handlers: SSEHandlers): () => void { + const source = new EventSource(url) + + source.onopen = () => handlers.onOpen?.() + + source.onerror = () => { + handlers.onRunError?.({ message: 'SSE connection error' }) + source.close() + } + + source.addEventListener('agent:start', (e: MessageEvent) => { + try { handlers.onAgentStart?.(JSON.parse(e.data)) } + catch { handlers.onRunError?.({ message: 'Failed to parse event data' }) } + }) + + source.addEventListener('agent:complete', (e: MessageEvent) => { + try { handlers.onAgentComplete?.(JSON.parse(e.data)) } + catch { handlers.onRunError?.({ message: 'Failed to parse event data' }) } + }) + + source.addEventListener('run:complete', (e: MessageEvent) => { + try { handlers.onRunComplete?.(JSON.parse(e.data)) } + catch { handlers.onRunError?.({ message: 'Failed to parse event data' }) } + source.close() + }) + + source.addEventListener('run:error', (e: MessageEvent) => { + try { handlers.onRunError?.(JSON.parse(e.data)) } + catch { handlers.onRunError?.({ message: 'Failed to parse event data' }) } + source.close() + }) + + return () => source.close() +} diff --git a/ui/lib/types/agents.ts b/ui/lib/types/agents.ts new file mode 100644 index 00000000..fff402dc --- /dev/null +++ b/ui/lib/types/agents.ts @@ -0,0 +1,6 @@ +import type { AgentStep } from './run' + +export type Decision = 'BUY' | 'SELL' | 'HOLD' +export type DebateTurn = { speaker: string; text: string } +export type PhaseReport = { step: AgentStep; content: string } +export type StepStatus = 'pending' | 'running' | 'done' diff --git a/ui/lib/types/run.ts b/ui/lib/types/run.ts new file mode 100644 index 00000000..3947976e --- /dev/null +++ b/ui/lib/types/run.ts @@ -0,0 +1,72 @@ +export type AgentStep = + | 'market_analyst' + | 'news_analyst' + | 'fundamentals_analyst' + | 'social_analyst' + | 'bull_researcher' + | 'bear_researcher' + | 'research_manager' + | 'trader' + | 'aggressive_analyst' + | 'conservative_analyst' + | 'neutral_analyst' + | 'risk_judge' + +export const AGENT_STEPS: AgentStep[] = [ + 'market_analyst', 'news_analyst', 'fundamentals_analyst', 'social_analyst', + 'bull_researcher', 'bear_researcher', 'research_manager', + 'trader', + 'aggressive_analyst', 'conservative_analyst', 'neutral_analyst', 'risk_judge', +] + +export const AGENT_STEP_LABELS: Record = { + market_analyst: 'Market', + news_analyst: 'News', + fundamentals_analyst: 'Fundamentals', + social_analyst: 'Social', + bull_researcher: 'Bull Researcher', + bear_researcher: 'Bear Researcher', + research_manager: 'Research Manager', + trader: 'Trader', + aggressive_analyst: 'Aggressive', + conservative_analyst: 'Conservative', + neutral_analyst: 'Neutral', + risk_judge: 'Risk Judge', +} + +export const STEP_PHASE: Record = { + market_analyst: 'analysts', + news_analyst: 'analysts', + fundamentals_analyst: 'analysts', + social_analyst: 'analysts', + bull_researcher: 'researchers', + bear_researcher: 'researchers', + research_manager: 'researchers', + trader: 'trader', + aggressive_analyst: 'risk', + conservative_analyst: 'risk', + neutral_analyst: 'risk', + risk_judge: 'risk', +} + +export type RunStatus = 'queued' | 'running' | 'complete' | 'error' + +export type RunConfig = { + ticker: string + date: string + llm_provider: string + deep_think_llm: string + quick_think_llm: string + max_debate_rounds: number + max_risk_discuss_rounds: number + enabled_analysts?: string[] +} + +export type RunSummary = { + id: string + ticker: string + date: string + status: RunStatus + decision: 'BUY' | 'SELL' | 'HOLD' | null + created_at: string +} diff --git a/ui/lib/types/settings.ts b/ui/lib/types/settings.ts new file mode 100644 index 00000000..b9671ec7 --- /dev/null +++ b/ui/lib/types/settings.ts @@ -0,0 +1,7 @@ +export type Settings = { + deep_think_llm: string + quick_think_llm: string + llm_provider: string + max_debate_rounds: number + max_risk_discuss_rounds: number +}