Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions next-app/app/api/chat/stream/route.ts
Original file line number Diff line number Diff line change
@@ -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); }
6 changes: 6 additions & 0 deletions next-app/app/api/intent/route.ts
Original file line number Diff line number Diff line change
@@ -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' } });
}
75 changes: 75 additions & 0 deletions next-app/app/chat/page.tsx
Original file line number Diff line number Diff line change
@@ -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<EventSource | null>(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 (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Chat</h1>
<div className="rounded border bg-white p-3">
<div className="space-y-3" role="log" aria-live="polite">
{messages.map((m, i) => (
<div key={i} className={m.role === 'user' ? 'text-right' : 'text-left'}>
<div className={"inline-block max-w-[80%] rounded px-3 py-2 " + (m.role==='user'?'bg-amber-100':'bg-slate-100')}>
<div className="whitespace-pre-wrap">{m.content}</div>
{m.role==='assistant' && m.meta && (
<div className="mt-1"><ProvenanceBadge meta={m.meta} /></div>
)}
</div>
</div>
))}
</div>
<div className="mt-3 flex gap-2">
<input value={input} onChange={e=>setInput(e.target.value)} className="flex-1 rounded border px-3 py-2" placeholder="Type a message..." />
<button onClick={send} disabled={streaming} className="rounded bg-amber-600 px-4 py-2 text-white disabled:opacity-50">Send</button>
{fallback && <span className="text-xs text-slate-500">Fallback in use</span>}
</div>
</div>
</div>
);
}
10 changes: 10 additions & 0 deletions next-app/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const metadata = { title: 'AGI/ASI Interface', description: 'AI Readiness MVP' };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen bg-gray-50 text-slate-900 antialiased">
<div className="mx-auto max-w-5xl p-4">{children}</div>
</body>
</html>
);
}
9 changes: 9 additions & 0 deletions next-app/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default function Home() {
return (
<main className="space-y-4">
<h1 className="text-2xl font-semibold">AGI/ASI Interface MVP</h1>
<p>Go to the chat to try streaming and provenance badges.</p>
<a className="text-amber-700 underline" href="/chat">Open Chat</a>
</main>
);
}
12 changes: 12 additions & 0 deletions next-app/components/ProvenanceBadge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span role="status" aria-label={`Model ${label}`} className="inline-flex items-center gap-1 rounded border px-2 py-0.5 text-xs text-slate-700">
<span className="h-2 w-2 rounded-full" style={{ background: color }} />
{label}
{meta.latencyMs != null && <span className="text-slate-500">• {meta.latencyMs}ms</span>}
</span>
);
}
12 changes: 12 additions & 0 deletions next-app/lib/ai/circuitBreaker.ts
Original file line number Diff line number Diff line change
@@ -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'; }
}
42 changes: 42 additions & 0 deletions next-app/lib/ai/orchestrator.ts
Original file line number Diff line number Diff line change
@@ -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<ModelResponse> {
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, unknown>): string {
return `<!-- orchestration:${JSON.stringify(meta)} -->\n${input}`;
}
}
5 changes: 5 additions & 0 deletions next-app/lib/ai/types.ts
Original file line number Diff line number Diff line change
@@ -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<StreamChunk>; meta: ProviderMeta }
export interface ModelProvider { id: string; supportsStreaming: boolean; invoke(prompt: string): Promise<ModelResponse>; stream(prompt: string): Promise<ModelResponse> }
6 changes: 6 additions & 0 deletions next-app/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: { serverActions: { allowedOrigins: ["*"] } },
reactStrictMode: true
};
module.exports = nextConfig;
29 changes: 29 additions & 0 deletions next-app/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
20 changes: 20 additions & 0 deletions next-app/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading