mirror of
https://github.com/zoo-apps/zoolabs.io.git
synced 2026-10-30 14:56:27 +00:00
The clip is the page rather than a picture on it, and the conversation was sitting in the middle of it — a column of type over the animal it exists to show. On a wide screen the column is now on the right and Blue swims in the two thirds nobody is typing over. It moves once, on the first question: before anyone speaks it sits wider and further in, because the greeting is the only content on the page and a narrow ribbon at the edge is not an opening; on the first question it settles to its talking width at the edge. Two concrete lengths, so it animates rather than jumping. A phone has no two thirds to spare, so below 60rem nothing changes. Blue also answers for the foundation now, not only for the ocean — on the front door that is most of what people ask. The counts come from the surface Blue is standing on: zoo.ngo counts its own papers, proposals and open models from the sources, and hands them over. Blue states those numbers and guesses none. They arrive as three integers in a plausible range or they do not arrive at all, because anything a URL can carry reaches a prompt.
228 lines
8.4 KiB
TypeScript
228 lines
8.4 KiB
TypeScript
import Head from 'next/head'
|
|
import Link from 'next/link'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { Anchor, Paragraph, XStack, YStack } from '@hanzo/ui'
|
|
import Film from '../components/Film'
|
|
import { Label } from '../components/kit'
|
|
import Speaker from '../components/Speaker'
|
|
import ZooLogo from '../components/ZooLogo'
|
|
import { reply, type Message } from '../lib/chat'
|
|
import { clipFor, OPENERS, RESTING, read, systemPrompt, visible, type Feeling } from '../lib/companion'
|
|
import { full, useArrival, useSeed } from '../lib/arrival'
|
|
import { hush, say } from '../lib/voice'
|
|
|
|
/**
|
|
* The logged-out door: Blue, full bleed, and one thing to do.
|
|
*
|
|
* This screen is deliberately not the lab. It is dark because it is underwater
|
|
* — the clip IS the page, not an illustration on it — and it holds a single
|
|
* question box, because someone arriving for the first time should not have to
|
|
* choose between eleven surfaces. The lab behind the sign-in is the light,
|
|
* hard-edged system every other Zoo surface uses; the two are different rooms
|
|
* and are meant to look it.
|
|
*/
|
|
|
|
type Turn = { role: 'user' | 'assistant'; text: string }
|
|
|
|
export default function Blue() {
|
|
const { embedded, seed, corpus } = useArrival()
|
|
const [turns, setTurns] = useState<Turn[]>([])
|
|
const [draft, setDraft] = useState('')
|
|
const [streaming, setStreaming] = useState('')
|
|
const [thinking, setThinking] = useState(false)
|
|
const [feeling, setFeeling] = useState<Feeling | null>(null)
|
|
const [problem, setProblem] = useState('')
|
|
const [voice, setVoice] = useState(false)
|
|
const [rest, setRest] = useState(0)
|
|
const tail = useRef<HTMLDivElement>(null)
|
|
|
|
const src = feeling ? clipFor(feeling) : RESTING[rest]
|
|
|
|
useEffect(() => {
|
|
if (turns.length || streaming) tail.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
|
}, [turns, streaming])
|
|
|
|
async function ask(question: string) {
|
|
if (!question.trim() || thinking) return
|
|
|
|
const asked: Turn = { role: 'user', text: question.trim() }
|
|
const history: Message[] = [
|
|
{ role: 'system', content: systemPrompt(corpus) },
|
|
...turns.map((t) => ({ role: t.role, content: t.text })),
|
|
{ role: 'user', content: asked.text },
|
|
]
|
|
|
|
setTurns((t) => [...t, asked])
|
|
setDraft('')
|
|
setProblem('')
|
|
setThinking(true)
|
|
hush()
|
|
|
|
let buffer = ''
|
|
try {
|
|
for await (const token of reply(history)) {
|
|
buffer += token
|
|
setStreaming(visible(buffer))
|
|
}
|
|
const { text, feeling: felt } = read(buffer)
|
|
setTurns((t) => [...t, { role: 'assistant', text }])
|
|
if (felt) setFeeling(felt)
|
|
if (voice) say(text)
|
|
} catch (err) {
|
|
setProblem(err instanceof Error ? err.message : String(err))
|
|
} finally {
|
|
setStreaming('')
|
|
setThinking(false)
|
|
}
|
|
}
|
|
|
|
useSeed(seed, ask)
|
|
|
|
const spoken = turns.length > 0 || Boolean(streaming) || Boolean(problem)
|
|
// What a reader carries with them when they open the full room.
|
|
const last = [...turns].reverse().find((t) => t.role === 'user')?.text ?? ''
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>Blue — Zoo Labs</title>
|
|
<meta
|
|
name="description"
|
|
content="Blue is a beluga whale and a marine scientist at Zoo Labs Foundation. Ask about the ocean, endangered species, or how research actually gets done."
|
|
/>
|
|
{/* This page paints its own dark ground rather than the site's paper. */}
|
|
<meta name="theme-color" content="#06202c" />
|
|
</Head>
|
|
|
|
<div className="deep">
|
|
<Film
|
|
src={src}
|
|
loop={Boolean(feeling)}
|
|
onEnded={() => !feeling && setRest((n) => (n + 1) % RESTING.length)}
|
|
/>
|
|
<div className="deep-veil" />
|
|
|
|
<div className={spoken ? 'deep-stage talking' : 'deep-stage'}>
|
|
{embedded ? (
|
|
/* Inside someone else's hero their page already carries a header,
|
|
and the one thing worth offering here is the whole room this is a
|
|
window into — with the question already asked. */
|
|
<XStack items="center" justify="flex-end" px="$4" py="$3">
|
|
<Anchor href={full(last)} target="_top" textDecorationLine="none">
|
|
<Label color="rgba(255,255,255,0.92)" fontWeight="600">
|
|
Open Blue ↗
|
|
</Label>
|
|
</Anchor>
|
|
</XStack>
|
|
) : (
|
|
<XStack items="center" gap="$3" px="$4" py="$3">
|
|
<XStack items="center" gap="$2">
|
|
<ZooLogo size={22} />
|
|
<Label color="rgba(255,255,255,0.92)" fontSize="$3" fontWeight="600">
|
|
Blue · Zoo
|
|
</Label>
|
|
</XStack>
|
|
|
|
<YStack flex={1} />
|
|
|
|
<Link href="/research">
|
|
<Label color="rgba(255,255,255,0.72)" fontWeight="500">
|
|
Research
|
|
</Label>
|
|
</Link>
|
|
<Anchor href="https://papers.zoo.ngo" textDecorationLine="none">
|
|
<Label color="rgba(255,255,255,0.72)" fontWeight="500">
|
|
Papers
|
|
</Label>
|
|
</Anchor>
|
|
<Anchor
|
|
href="https://zoolabs.id?client_id=zoo-app&redirect_uri=https://zoolabs.io"
|
|
textDecorationLine="none"
|
|
>
|
|
<Label color="rgba(255,255,255,0.92)" fontWeight="600">
|
|
Sign in
|
|
</Label>
|
|
</Anchor>
|
|
</XStack>
|
|
)}
|
|
|
|
<YStack className="deep-middle" flex={1} justify="center" items="center" px="$4" gap="$4">
|
|
{!spoken && (
|
|
<YStack items="center" gap="$3" maxW={560} style={{ textAlign: 'center' }}>
|
|
<h1>Hi, I’m Blue the beluga.</h1>
|
|
<Paragraph color="rgba(255,255,255,0.72)">
|
|
Ask me about the ocean, endangered species, or how scientists actually find
|
|
things out. Watch how I feel as we talk.
|
|
</Paragraph>
|
|
<XStack flexWrap="wrap" justify="center" gap="$2" mt="$2">
|
|
{OPENERS.slice(0, 3).map((opener) => (
|
|
<button key={opener} className="deep-chip" onClick={() => ask(opener)}>
|
|
{opener}
|
|
</button>
|
|
))}
|
|
</XStack>
|
|
</YStack>
|
|
)}
|
|
|
|
{spoken && (
|
|
<div className="deep-thread">
|
|
{turns.map((turn, i) => (
|
|
<div key={i} className={turn.role === 'user' ? 'deep-said deep-mine' : 'deep-said'}>
|
|
{turn.text}
|
|
</div>
|
|
))}
|
|
{streaming ? <div className="deep-said">{streaming}</div> : null}
|
|
{problem ? (
|
|
<div className="deep-said deep-wrong" role="alert">
|
|
Blue could not answer — {problem}. Nothing was made up in its place.
|
|
</div>
|
|
) : null}
|
|
<div ref={tail} />
|
|
</div>
|
|
)}
|
|
</YStack>
|
|
|
|
<YStack className="deep-ask-row" items="center" px="$4" pb="$5" gap="$2">
|
|
<form
|
|
className="deep-ask"
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
ask(draft)
|
|
}}
|
|
>
|
|
<input
|
|
id="question"
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
placeholder="Talk to Blue…"
|
|
aria-label="Talk to Blue"
|
|
autoComplete="off"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const on = !voice
|
|
setVoice(on)
|
|
if (!on) hush()
|
|
else if (turns.length) say(turns[turns.length - 1].text)
|
|
}}
|
|
aria-pressed={voice}
|
|
aria-label={voice ? 'Stop reading answers aloud' : 'Read answers aloud'}
|
|
className="deep-voice"
|
|
>
|
|
<Speaker on={voice} size={17} />
|
|
</button>
|
|
<button type="submit" disabled={thinking || !draft.trim()} aria-label="Ask Blue">
|
|
{thinking ? '…' : '↑'}
|
|
</button>
|
|
</form>
|
|
<Label color="rgba(255,255,255,0.55)" fontSize="$1" fontWeight="400">
|
|
{thinking ? 'Blue is thinking…' : feeling ? `Blue feels ${feeling}` : 'Open source · 501(c)(3)'}
|
|
</Label>
|
|
</YStack>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|