156 lines
5.0 KiB
TypeScript
156 lines
5.0 KiB
TypeScript
import { useState, useCallback } from 'react'
|
|
import { AvatarGrid } from './components/AvatarGrid'
|
|
import { ConsensusPanel } from './components/ConsensusPanel'
|
|
import { ChatMessage } from './components/ChatMessage'
|
|
import type { HeadContribution, FinalResponse } from './types'
|
|
import './App.css'
|
|
|
|
type ViewMode = 'normal' | 'explain' | 'developer'
|
|
|
|
function App() {
|
|
const [sessionId, setSessionId] = useState<string | null>(null)
|
|
const [prompt, setPrompt] = useState('')
|
|
const [messages, setMessages] = useState<{ role: 'user' | 'assistant'; content: string; data?: FinalResponse }[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [activeHeads, setActiveHeads] = useState<string[]>([])
|
|
const [speakingHead, setSpeakingHead] = useState<string | null>(null) // current head "speaking" in UI
|
|
const [headSummaries, setHeadSummaries] = useState<Record<string, string>>({})
|
|
const [viewMode, setViewMode] = useState<ViewMode>('normal')
|
|
const [lastResponse, setLastResponse] = useState<FinalResponse | null>(null)
|
|
|
|
const parseJson = useCallback(async (r: Response) => {
|
|
const text = await r.text()
|
|
if (!text.trim()) throw new Error('Empty response from API')
|
|
try {
|
|
return JSON.parse(text)
|
|
} catch {
|
|
throw new Error(`Invalid JSON from API: ${text.slice(0, 100)}`)
|
|
}
|
|
}, [])
|
|
|
|
const ensureSession = useCallback(async () => {
|
|
if (sessionId) return sessionId
|
|
const r = await fetch('/v1/sessions', { method: 'POST' })
|
|
const j = await parseJson(r)
|
|
if (!j.session_id) throw new Error('No session_id in response')
|
|
setSessionId(j.session_id)
|
|
return j.session_id
|
|
}, [sessionId, parseJson])
|
|
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!prompt.trim()) return
|
|
const sid = await ensureSession()
|
|
if (!sid) return
|
|
|
|
setMessages((m) => [...m, { role: 'user', content: prompt }])
|
|
setPrompt('')
|
|
setLoading(true)
|
|
setSpeakingHead(null)
|
|
setActiveHeads(['logic', 'research', 'strategy', 'security', 'safety'])
|
|
|
|
try {
|
|
const r = await fetch(`/v1/sessions/${sid}/prompt`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ prompt }),
|
|
})
|
|
const data = await parseJson(r)
|
|
if (!r.ok) throw new Error(data.detail || 'Request failed')
|
|
|
|
setLastResponse(data)
|
|
if (data.response_mode === 'show_dissent' || data.response_mode === 'explain') {
|
|
setViewMode('explain')
|
|
}
|
|
const contribs = data.head_contributions || []
|
|
setHeadSummaries(
|
|
Object.fromEntries(contribs.map((c: { head_id: string; summary: string }) => [c.head_id, c.summary]))
|
|
)
|
|
setSpeakingHead(contribs[0]?.head_id ?? null)
|
|
setMessages((m) => [
|
|
...m,
|
|
{
|
|
role: 'assistant',
|
|
content: data.final_answer,
|
|
data,
|
|
},
|
|
])
|
|
} catch (e) {
|
|
setMessages((m) => [
|
|
...m,
|
|
{ role: 'assistant', content: `Error: ${(e as Error).message}`, data: undefined },
|
|
])
|
|
} finally {
|
|
setLoading(false)
|
|
setActiveHeads([])
|
|
}
|
|
}, [prompt, ensureSession, parseJson])
|
|
|
|
const HEAD_IDS = [
|
|
'logic', 'research', 'systems', 'strategy', 'product',
|
|
'security', 'safety', 'reliability', 'cost', 'data', 'devex', 'witness',
|
|
]
|
|
|
|
return (
|
|
<div className="app">
|
|
<header className="header">
|
|
<h1>FusionAGI Dvādaśa</h1>
|
|
<div className="mode-toggle">
|
|
{(['normal', 'explain', 'developer'] as const).map((m) => (
|
|
<button
|
|
key={m}
|
|
className={viewMode === m ? 'active' : ''}
|
|
onClick={() => setViewMode(m)}
|
|
>
|
|
{m}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</header>
|
|
|
|
<div className="main">
|
|
<div className="chat-area">
|
|
<AvatarGrid
|
|
headIds={HEAD_IDS}
|
|
activeHeads={activeHeads}
|
|
speakingHead={speakingHead}
|
|
headSummaries={headSummaries}
|
|
/>
|
|
<div className="messages">
|
|
{messages.map((msg, i) => (
|
|
<ChatMessage
|
|
key={i}
|
|
message={msg}
|
|
viewMode={viewMode}
|
|
/>
|
|
))}
|
|
{loading && <div className="loading">Heads running…</div>}
|
|
</div>
|
|
<div className="input-row">
|
|
<input
|
|
id="prompt-input"
|
|
name="prompt"
|
|
type="text"
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
|
placeholder="Ask FusionAGI… (/head strategy, /show dissent)"
|
|
autoComplete="off"
|
|
aria-label="Ask FusionAGI"
|
|
/>
|
|
<button onClick={handleSubmit} disabled={loading}>
|
|
Send
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<ConsensusPanel
|
|
response={lastResponse}
|
|
viewMode={viewMode}
|
|
expanded={viewMode !== 'normal'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default App
|