From e65026e152969e36ed4ddddfe1406d0ce445d176 Mon Sep 17 00:00:00 2001 From: OneFineStarstuff Date: Thu, 11 Sep 2025 08:51:17 +0000 Subject: [PATCH 1/4] feat(next): scaffold AGI/ASI MVP with SSE chat, orchestrator, circuit breaker, provenance badge, intent endpoint --- next-app/app/api/chat/stream/route.ts | 42 ++++++++++++++ next-app/app/api/intent/route.ts | 6 ++ next-app/app/chat/page.tsx | 75 +++++++++++++++++++++++++ next-app/app/layout.tsx | 10 ++++ next-app/app/page.tsx | 9 +++ next-app/components/ProvenanceBadge.tsx | 12 ++++ next-app/lib/ai/circuitBreaker.ts | 12 ++++ next-app/lib/ai/orchestrator.ts | 42 ++++++++++++++ next-app/lib/ai/types.ts | 5 ++ next-app/next.config.js | 6 ++ next-app/package.json | 29 ++++++++++ next-app/tsconfig.json | 20 +++++++ 12 files changed, 268 insertions(+) create mode 100644 next-app/app/api/chat/stream/route.ts create mode 100644 next-app/app/api/intent/route.ts create mode 100644 next-app/app/chat/page.tsx create mode 100644 next-app/app/layout.tsx create mode 100644 next-app/app/page.tsx create mode 100644 next-app/components/ProvenanceBadge.tsx create mode 100644 next-app/lib/ai/circuitBreaker.ts create mode 100644 next-app/lib/ai/orchestrator.ts create mode 100644 next-app/lib/ai/types.ts create mode 100644 next-app/next.config.js create mode 100644 next-app/package.json create mode 100644 next-app/tsconfig.json diff --git a/next-app/app/api/chat/stream/route.ts b/next-app/app/api/chat/stream/route.ts new file mode 100644 index 00000000..a27442aa --- /dev/null +++ b/next-app/app/api/chat/stream/route.ts @@ -0,0 +1,42 @@ +import { NextRequest } from 'next/server'; + +export const runtime = 'nodejs'; + +function* fakeStream(text: string) { + for (const ch of text) { + yield { delta: ch }; + } +} + +export async function POST(req: NextRequest) { + const { message } = await req.json(); + const ctrl = new AbortController(); + const stream = new ReadableStream({ + async start(controller) { + try { + const reply = `Echo: ${message}`; + const meta = { layer: 'surface', model: 'mock', version: '0.0.1', latencyMs: 42 }; + controller.enqueue(encode(`event: meta\ndata: ${JSON.stringify(meta)}\n\n`)); + for (const chunk of fakeStream(reply)) { + await new Promise(r => setTimeout(r, 10)); + controller.enqueue(encode(`event: token\ndata: ${JSON.stringify(chunk)}\n\n`)); + } + controller.enqueue(encode(`event: done\n\n`)); + controller.close(); + } catch (e) { + controller.enqueue(encode(`event: error\ndata: {"message":"stream_failed"}\n\n`)); + controller.close(); + } + }, + cancel() { ctrl.abort(); } + }); + return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' } }); +} + +export async function GET() { + // Keep the SSE connection open; body is ignored, we stream via POST response + const stream = new ReadableStream({ start(){} }); + return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } }); +} + +function encode(s: string) { return new TextEncoder().encode(s); } diff --git a/next-app/app/api/intent/route.ts b/next-app/app/api/intent/route.ts new file mode 100644 index 00000000..ad2a901b --- /dev/null +++ b/next-app/app/api/intent/route.ts @@ -0,0 +1,6 @@ +export const runtime = 'edge'; +export async function POST(req: Request) { + const { message } = await req.json(); + const intent = /simulate|prove|optimize|model/i.test(message) ? 'analytical' : 'casual'; + return new Response(JSON.stringify({ intent }), { headers: { 'content-type': 'application/json' } }); +} diff --git a/next-app/app/chat/page.tsx b/next-app/app/chat/page.tsx new file mode 100644 index 00000000..ccbbd640 --- /dev/null +++ b/next-app/app/chat/page.tsx @@ -0,0 +1,75 @@ +"use client"; +import { useEffect, useRef, useState } from 'react'; +import { ProvenanceBadge } from '@/components/ProvenanceBadge'; + +export default function ChatPage() { + const [input, setInput] = useState(""); + const [messages, setMessages] = useState<{ role: 'user'|'assistant'; content: string; meta?: any }[]>([]); + const [streaming, setStreaming] = useState(false); + const [fallback, setFallback] = useState(false); + const eventSrc = useRef(null); + + const send = async () => { + if (!input.trim() || streaming) return; + const userMsg = { role: 'user' as const, content: input }; + setMessages(m => [...m, userMsg, { role: 'assistant', content: '' }]); + setInput(""); + setStreaming(true); + const es = new EventSource('/api/chat/stream?s='+Date.now(), { withCredentials: false }); + eventSrc.current = es; + + es.addEventListener('token', (e: MessageEvent) => { + const data = JSON.parse(e.data); + setMessages(m => { + const copy = [...m]; + const idx = copy.length - 1; // last assistant + copy[idx] = { ...copy[idx], content: (copy[idx].content || '') + data.delta }; + return copy; + }); + }); + + es.addEventListener('meta', (e: MessageEvent) => { + const meta = JSON.parse(e.data); + if (meta.fallback) setFallback(true); + setMessages(m => { + const copy = [...m]; + const idx = copy.length - 1; + copy[idx] = { ...copy[idx], meta }; + return copy; + }); + }); + + es.addEventListener('done', () => { setStreaming(false); es.close(); eventSrc.current = null; }); + es.addEventListener('error', () => { setStreaming(false); es.close(); eventSrc.current = null; }); + + // Kick off the request body via fetch; SSE connection carries the stream + await fetch('/api/chat/stream', { method: 'POST', body: JSON.stringify({ message: userMsg.content }) }); + }; + + useEffect(() => () => { eventSrc.current?.close(); }, []); + + return ( +
+

Chat

+
+
+ {messages.map((m, i) => ( +
+
+
{m.content}
+ {m.role==='assistant' && m.meta && ( +
+ )} +
+
+ ))} +
+
+ setInput(e.target.value)} className="flex-1 rounded border px-3 py-2" placeholder="Type a message..." /> + + {fallback && Fallback in use} +
+
+
+ ); +} diff --git a/next-app/app/layout.tsx b/next-app/app/layout.tsx new file mode 100644 index 00000000..1797da10 --- /dev/null +++ b/next-app/app/layout.tsx @@ -0,0 +1,10 @@ +export const metadata = { title: 'AGI/ASI Interface', description: 'AI Readiness MVP' }; +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +
{children}
+ + + ); +} diff --git a/next-app/app/page.tsx b/next-app/app/page.tsx new file mode 100644 index 00000000..01aca143 --- /dev/null +++ b/next-app/app/page.tsx @@ -0,0 +1,9 @@ +export default function Home() { + return ( +
+

AGI/ASI Interface MVP

+

Go to the chat to try streaming and provenance badges.

+ Open Chat +
+ ); +} diff --git a/next-app/components/ProvenanceBadge.tsx b/next-app/components/ProvenanceBadge.tsx new file mode 100644 index 00000000..65c4e461 --- /dev/null +++ b/next-app/components/ProvenanceBadge.tsx @@ -0,0 +1,12 @@ +"use client"; +export function ProvenanceBadge({ meta }: { meta: { name?: string; model?: string; version?: string; layer?: string; latencyMs?: number } }) { + const label = `${meta.layer ?? 'surface'} • ${meta.name ?? meta.model ?? 'model'} ${meta.version ?? ''}`; + const color = (meta.layer ?? 'surface') === 'surface' ? '#38A169' : '#1A237E'; + return ( + + + {label} + {meta.latencyMs != null && • {meta.latencyMs}ms} + + ); +} diff --git a/next-app/lib/ai/circuitBreaker.ts b/next-app/lib/ai/circuitBreaker.ts new file mode 100644 index 00000000..6045de03 --- /dev/null +++ b/next-app/lib/ai/circuitBreaker.ts @@ -0,0 +1,12 @@ +type State = 'closed' | 'open' | 'half-open'; +export class CircuitBreaker { + private failures = 0; private state: State = 'closed'; private openedAt = 0; + constructor(private failureThreshold = 3, private recoveryMs = 15000) {} + canPass(): boolean { + if (this.state === 'open' && Date.now() - this.openedAt > this.recoveryMs) { this.state = 'half-open'; return true; } + return this.state !== 'open'; + } + recordSuccess() { this.failures = 0; this.state = 'closed'; } + recordFailure() { this.failures++; if (this.failures >= this.failureThreshold) { this.state = 'open'; this.openedAt = Date.now(); } } + isOpen() { return this.state === 'open'; } +} diff --git a/next-app/lib/ai/orchestrator.ts b/next-app/lib/ai/orchestrator.ts new file mode 100644 index 00000000..2d7d4162 --- /dev/null +++ b/next-app/lib/ai/orchestrator.ts @@ -0,0 +1,42 @@ +import { CircuitBreaker } from './circuitBreaker'; +import type { ModelProvider, ModelResponse } from './types'; + +type Intent = 'casual' | 'actionable' | 'analytical' | 'sensitive'; +export type RouteDecision = { intent: Intent; target: 'surface' | 'depth'; reason: string }; + +export class Orchestrator { + private breakerDepth = new CircuitBreaker(3, 15000); + constructor(private surface: ModelProvider, private depth: ModelProvider, private intentDetect: (msg: string) => Intent) {} + + route(input: string, override?: 'surface' | 'depth'): RouteDecision { + if (override) return { intent: this.intentDetect(input), target: override, reason: 'user_override' }; + const intent = this.intentDetect(input); + const target = intent === 'analytical' ? 'depth' : 'surface'; + return { intent, target, reason: 'policy' }; + } + + async respond(input: string, stream = true): Promise { + const decision = this.route(input); + const primary = decision.target === 'depth' ? this.depth : this.surface; + const fallback = decision.target === 'depth' ? this.surface : this.depth; + + if (decision.target === 'depth' && !this.breakerDepth.canPass()) { + return this.surface.invoke(this.decorate(input, { fallback: 'depth_breaker_open' })); + } + + try { + const res = stream && primary.supportsStreaming + ? await primary.stream(this.decorate(input, decision)) + : await primary.invoke(this.decorate(input, decision)); + if (decision.target === 'depth') this.breakerDepth.recordSuccess(); + return res; + } catch (e) { + if (decision.target === 'depth') this.breakerDepth.recordFailure(); + return fallback.invoke(this.decorate(input, { fallback: 'primary_failed' })); + } + } + + private decorate(input: string, meta: Record): string { + return `\n${input}`; + } +} diff --git a/next-app/lib/ai/types.ts b/next-app/lib/ai/types.ts new file mode 100644 index 00000000..2a41cf79 --- /dev/null +++ b/next-app/lib/ai/types.ts @@ -0,0 +1,5 @@ +export type ModelConfig = { temperature?: number; maxTokens?: number }; +export type StreamChunk = { id?: string; delta: string; done?: boolean }; +export type ProviderMeta = { name?: string; model?: string; layer?: 'surface' | 'depth'; version?: string; tokensIn?: number; tokensOut?: number; latencyMs?: number }; +export interface ModelResponse { text?: string; chunks?: AsyncIterable; meta: ProviderMeta } +export interface ModelProvider { id: string; supportsStreaming: boolean; invoke(prompt: string): Promise; stream(prompt: string): Promise } diff --git a/next-app/next.config.js b/next-app/next.config.js new file mode 100644 index 00000000..5d68f2ec --- /dev/null +++ b/next-app/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { serverActions: { allowedOrigins: ["*"] } }, + reactStrictMode: true +}; +module.exports = nextConfig; diff --git a/next-app/package.json b/next-app/package.json new file mode 100644 index 00000000..a2cc663c --- /dev/null +++ b/next-app/package.json @@ -0,0 +1,29 @@ +{ + "name": "agi-asi-interface-mvp", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "next": "14.2.5", + "react": "18.3.1", + "react-dom": "18.3.1", + "zustand": "4.5.2", + "classnames": "2.5.1" + }, + "devDependencies": { + "@types/node": "20.11.19", + "@types/react": "18.2.67", + "@types/react-dom": "18.2.21", + "eslint": "8.57.0", + "eslint-config-next": "14.2.5", + "typescript": "5.4.5", + "vitest": "1.6.0" + } +} diff --git a/next-app/tsconfig.json b/next-app/tsconfig.json new file mode 100644 index 00000000..1dd4f4ca --- /dev/null +++ b/next-app/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "baseUrl": ".", + "paths": {"@/*": ["./*"]} + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} From 1abd014cd5e29e0cad706f6444c835869f480d51 Mon Sep 17 00:00:00 2001 From: OneFineStarstuff Date: Thu, 11 Sep 2025 08:53:50 +0000 Subject: [PATCH 2/4] fix(next): make SSE route stream on GET with query and POST body; factor helper --- next-app/app/api/chat/stream/route.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/next-app/app/api/chat/stream/route.ts b/next-app/app/api/chat/stream/route.ts index a27442aa..a983cd75 100644 --- a/next-app/app/api/chat/stream/route.ts +++ b/next-app/app/api/chat/stream/route.ts @@ -8,10 +8,9 @@ function* fakeStream(text: string) { } } -export async function POST(req: NextRequest) { - const { message } = await req.json(); +function streamForMessage(message: string) { const ctrl = new AbortController(); - const stream = new ReadableStream({ + const stream = new ReadableStream({ async start(controller) { try { const reply = `Echo: ${message}`; @@ -33,10 +32,15 @@ export async function POST(req: NextRequest) { return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' } }); } -export async function GET() { - // Keep the SSE connection open; body is ignored, we stream via POST response - const stream = new ReadableStream({ start(){} }); - return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } }); +export async function POST(req: NextRequest) { + const { message } = await req.json(); + return streamForMessage(message); +} + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const message = searchParams.get('q') ?? ''; + return streamForMessage(message); } function encode(s: string) { return new TextEncoder().encode(s); } From 0616a117ba90c86b8fd93bfb8655b649dffdccf3 Mon Sep 17 00:00:00 2001 From: OneFineStarstuff Date: Thu, 11 Sep 2025 08:56:51 +0000 Subject: [PATCH 3/4] fix(chat): use GET SSE query param instead of dual POST/GET; simpler client flow --- next-app/app/chat/page.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/next-app/app/chat/page.tsx b/next-app/app/chat/page.tsx index ccbbd640..4c47988a 100644 --- a/next-app/app/chat/page.tsx +++ b/next-app/app/chat/page.tsx @@ -15,7 +15,7 @@ export default function ChatPage() { setMessages(m => [...m, userMsg, { role: 'assistant', content: '' }]); setInput(""); setStreaming(true); - const es = new EventSource('/api/chat/stream?s='+Date.now(), { withCredentials: false }); + const es = new EventSource(`/api/chat/stream?q=${encodeURIComponent(userMsg.content)}&s=${Date.now()}` , { withCredentials: false }); eventSrc.current = es; es.addEventListener('token', (e: MessageEvent) => { @@ -42,8 +42,7 @@ export default function ChatPage() { es.addEventListener('done', () => { setStreaming(false); es.close(); eventSrc.current = null; }); es.addEventListener('error', () => { setStreaming(false); es.close(); eventSrc.current = null; }); - // Kick off the request body via fetch; SSE connection carries the stream - await fetch('/api/chat/stream', { method: 'POST', body: JSON.stringify({ message: userMsg.content }) }); + // Using GET-only SSE with query payload; no POST body needed }; useEffect(() => () => { eventSrc.current?.close(); }, []); From 8e903d87174a8d80ab859096087724fe8307650a Mon Sep 17 00:00:00 2001 From: OneFineStarstuff Date: Thu, 11 Sep 2025 08:57:50 +0000 Subject: [PATCH 4/4] chore(next): type metadata and suppressHydrationWarning in RootLayout --- next-app/app/layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/next-app/app/layout.tsx b/next-app/app/layout.tsx index 1797da10..2da70423 100644 --- a/next-app/app/layout.tsx +++ b/next-app/app/layout.tsx @@ -1,8 +1,8 @@ -export const metadata = { title: 'AGI/ASI Interface', description: 'AI Readiness MVP' }; +export const metadata = { title: 'AGI/ASI Interface', description: 'AI Readiness MVP' } satisfies import('next').Metadata; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - +
{children}