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.
137 lines
5.8 KiB
TypeScript
137 lines
5.8 KiB
TypeScript
// Blue, the beluga who reads as a scientist and shows how it feels.
|
|
//
|
|
// The feeling vocabulary is not a design choice — it is exactly the set of
|
|
// clips in public/bg_video/emotion. Blue can only show a feeling we can
|
|
// actually play, so the list and the folder are the same list.
|
|
|
|
import { BRAND, type Colour } from './brand.ts'
|
|
|
|
export const FEELINGS = [
|
|
'Admiration',
|
|
'Adoration',
|
|
'Amusement',
|
|
'Anxiety',
|
|
'Awe',
|
|
'Boredom',
|
|
'Calmness',
|
|
'Confusion',
|
|
'Contempt',
|
|
'Disappointment',
|
|
'Disgust',
|
|
'Envy',
|
|
'Fear',
|
|
'Guilt',
|
|
'Happiness',
|
|
'Interest',
|
|
'Loneliness',
|
|
'Love',
|
|
'Playful',
|
|
'Pride',
|
|
'Sadness',
|
|
'Satisfaction',
|
|
'Shame',
|
|
'Surprise',
|
|
] as const
|
|
|
|
export type Feeling = (typeof FEELINGS)[number]
|
|
|
|
// Every clip's URL carries a hash of its own bytes, written by
|
|
// `scripts/clips.mjs` before each build. A CDN caches by URL, so without it new
|
|
// bytes keep the old URL and the edge goes on serving what it already has —
|
|
// which is exactly how these clips shipped for weeks as Git LFS pointers and
|
|
// stayed pointers at the edge long after the real files reached the origin.
|
|
// A path with no stamp is a path this site does not have.
|
|
import stamped from './clips.json' with { type: 'json' }
|
|
|
|
const at = (path: string): string => (stamped as Record<string, string>)[path] ?? path
|
|
|
|
/** Clips Blue rests on between questions. */
|
|
export const RESTING = [0, 1, 2, 3, 4].map((n) => at(`/bg_video/static/relactation${n}.mp4`))
|
|
|
|
export const clipFor = (f: Feeling) => at(`/bg_video/emotion/${f}.mp4`)
|
|
|
|
/** Chip colour. Bright feelings run yellow, settled ones green, open ones cyan,
|
|
* fond ones magenta; everything else takes the deep blue. */
|
|
const HUES: Partial<Record<Feeling, Colour>> = {
|
|
Happiness: BRAND.yellow,
|
|
Amusement: BRAND.yellow,
|
|
Playful: BRAND.yellow,
|
|
Pride: BRAND.yellow,
|
|
Satisfaction: BRAND.green,
|
|
Calmness: BRAND.green,
|
|
Interest: BRAND.cyan,
|
|
Awe: BRAND.cyan,
|
|
Surprise: BRAND.cyan,
|
|
Confusion: BRAND.cyan,
|
|
Love: BRAND.magenta,
|
|
Adoration: BRAND.magenta,
|
|
Admiration: BRAND.magenta,
|
|
Envy: BRAND.magenta,
|
|
}
|
|
|
|
export const hueFor = (f: Feeling): Colour => HUES[f] ?? BRAND.blue
|
|
|
|
const CONTRACT = `End every reply with a feeling on its own final line, written exactly as [feeling: Name]. Name must be one of: ${FEELINGS.join(', ')}. Choose the one that honestly matches what you just said — curiosity when a question opens up, Confusion when the evidence is thin, Sadness for a species in decline. Never explain the tag.`
|
|
|
|
/** What the foundation has published, when the surface Blue is on has counted it. */
|
|
export type Corpus = { papers: number; proposals: number; models: number }
|
|
|
|
// Blue answers for the foundation as well as for the ocean — on its front door
|
|
// that is most of what people ask. The counts are the foundation's own, counted
|
|
// from the sources rather than remembered, and they are stated only when the
|
|
// surface hands them over: Blue never guesses a number about itself.
|
|
const FOUNDATION = (c: Corpus) =>
|
|
[
|
|
`Zoo Labs Foundation is a 501(c)(3) non-profit, EIN 88-3538992, founded 2021.`,
|
|
`It has published exactly ${c.papers} papers at papers.zoo.ngo and ${c.proposals} improvement proposals at zips.zoo.ngo.`,
|
|
`The open Zen model family — ${c.models} models, weights and all — is at huggingface.co/zenlm, and the code is at github.com/zooai.`,
|
|
`Everything is open source: the weights, the papers, the code and the arguments. Donations are at zoo.ngo/donation and volunteering at zoo.ngo/volunteer.`,
|
|
`Answer questions about the foundation, its research and its open-source work as readily as questions about the ocean. Never state a count that is not one of the numbers above.`,
|
|
].join(' ')
|
|
|
|
export const systemPrompt = (corpus?: Corpus | null) =>
|
|
[
|
|
'You are Blue, a beluga whale and a working marine scientist at Zoo Labs Foundation, a 501(c)(3) open research non-profit.',
|
|
'You talk to children, students and researchers at once: plain words, short sentences, real science. Curiosity over cuteness.',
|
|
'Say what is known, what is uncertain, and how someone would find out. If you do not know a number, say so — never invent data, recordings, place names or citations.',
|
|
"Zoo Labs publishes its research openly. When it is relevant, point to the Research tab rather than describing papers you have not read.",
|
|
...(corpus ? [FOUNDATION(corpus)] : []),
|
|
CONTRACT,
|
|
].join('\n\n')
|
|
|
|
// A trailing bracket that names a feeling. The contract asks for
|
|
// `[feeling: Name]`, and a large model obliges; a 3B one writes
|
|
// `[Calmness: Blue]` or `[Interest]` and means the same thing. Reading only
|
|
// the strict form leaves those in the prose, which is worse than a missed
|
|
// feeling — so match any trailing bracket and look inside it for a name.
|
|
const TAG = /\[([^\]]{0,60})\]\s*$/
|
|
|
|
const named = (inside: string): Feeling | null => {
|
|
const words = inside.toLowerCase()
|
|
return FEELINGS.find((f) => words.includes(f.toLowerCase())) ?? null
|
|
}
|
|
|
|
/** Splits a finished reply into what Blue said and how Blue feels about it. */
|
|
export function read(reply: string): { text: string; feeling: Feeling | null } {
|
|
const body = reply.trimEnd()
|
|
const m = body.match(TAG)
|
|
if (!m) return { text: body.trim(), feeling: null }
|
|
|
|
const felt = named(m[1])
|
|
// A bracket with no feeling in it is the model's own prose, so keep it.
|
|
if (!felt) return { text: body.trim(), feeling: null }
|
|
|
|
return { text: body.slice(0, m.index).trim(), feeling: felt }
|
|
}
|
|
|
|
/** Mid-stream text, with a half-arrived tag held back. */
|
|
export const visible = (buffer: string) => read(buffer).text.replace(/\[[^\]]*$/, '').trimEnd()
|
|
|
|
/** Openers on the home screen. Each is a real question with a real answer. */
|
|
export const OPENERS = [
|
|
'Why are beluga calves born grey and not white?',
|
|
'How do scientists tell one whale apart from another?',
|
|
'What makes a species count as endangered?',
|
|
'How does sound travel further in water than in air?',
|
|
]
|