ElisaTrippetti commited on
Commit
0f3c25b
·
verified ·
1 Parent(s): b02f001

Upload 5 files

Browse files
Files changed (5) hide show
  1. README.md +45 -7
  2. chat-engine.jsx +174 -0
  3. debug-panel.jsx +143 -0
  4. index.html +63 -17
  5. variant-terminal.jsx +248 -0
README.md CHANGED
@@ -1,12 +1,50 @@
1
  ---
2
- title: Fetch Pup Demo
3
- emoji: 📉
4
- colorFrom: red
5
- colorTo: purple
6
  sdk: static
7
  pinned: false
8
- license: mit
9
- short_description: Dog-loving BlenderBot. Zero-backend static showcase
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: This is Fetch
3
+ emoji: 🐕
4
+ colorFrom: gray
5
+ colorTo: yellow
6
  sdk: static
7
  pinned: false
 
 
8
  ---
9
 
10
+ # This is Fetch
11
+
12
+ A friendly chatbot with a soft spot for dogs.
13
+
14
+ This is the **terminal** design variant, deployed as a HuggingFace Static
15
+ Space. It runs entirely in the browser — no GPU, no Python, no backend.
16
+
17
+ ## How it works
18
+
19
+ Fetch answers using the same logic as the original Python reference:
20
+
21
+ - A small **keyword lookup** table (dogs, breeds, care, trivia, reactions).
22
+ If the user's question contains a known keyword, the matching fact is
23
+ returned verbatim.
24
+ - Three **fallback replies** cover the "I don't know" case.
25
+ - **Four-turn context** is kept in memory for the session.
26
+
27
+ The generative path (BlenderBot-400M-distill in the original, or an
28
+ LLM-backed helper in the Claude preview) is intentionally disabled in this
29
+ static build. Static Spaces don't run Python or serve models, so the
30
+ deterministic keyword-lookup path is the honest option here.
31
+
32
+ If you want the generative version, deploy the original `app.py` to a
33
+ Gradio Space instead (`sdk: gradio`, CPU hardware is fine).
34
+
35
+ ## Files
36
+
37
+ - `index.html` — app shell, loads React + Babel, mounts the terminal variant
38
+ - `chat-engine.jsx` — keyword lookup, fallbacks, suggestion rotator, chat hook
39
+ - `variant-terminal.jsx` — the terminal CLI-style UI
40
+ - `debug-panel.jsx` — optional ⌘D devtools panel (exchange log, keyword table)
41
+
42
+ ## Keyboard
43
+
44
+ - **⌘K / Ctrl+K** — clear the conversation
45
+ - **⌘D / Ctrl+D** — toggle the devtools panel
46
+
47
+ ## Credits
48
+
49
+ Design and frontend: a set of explorations for *This is Fetch*.
50
+ Reference model: [facebook/blenderbot-400M-distill](https://huggingface.co/facebook/blenderbot-400M-distill).
chat-engine.jsx ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // chat-engine.jsx — shared chat logic for all Fetch variants.
2
+ // Mirrors the Python reference: 4-turn context, keyword lookup, fallbacks.
3
+ // Two backends: "claude" (window.claude.complete) and "local" (keyword
4
+ // lookup + fallback only — the path HuggingFace Static Spaces will take).
5
+
6
+ const KNOWLEDGE_BASE = [
7
+ ["domesticate", "Dogs have been domesticated for at least 15,000 years, making them the oldest domesticated animal."],
8
+ ["skills", "Dogs can understand up to 250 words and gestures, and can count up to 5."],
9
+ ["border collie","Border Collies are considered the most intelligent dog breed, capable of learning a new command in under 5 seconds."],
10
+ ["greyhound", "Greyhounds can reach speeds of up to 45 mph, yet are famously calm and lazy indoors."],
11
+ ["exercise", "Dogs need mental stimulation as much as physical exercise. A bored dog is often a destructive dog."],
12
+ ["dental", "Regular dental care is one of the most overlooked aspects of dog health. Most dogs show signs of dental disease by age 3."],
13
+ ["bark", "The Basenji is the only dog breed that doesn't bark... it yodels."],
14
+ ["smell", "Dogs have a sense of smell estimated to be 10,000 to 100,000 times more acute than humans."],
15
+ ["dream", "Dogs dream just like humans do. Research suggests they replay their day while sleeping, and smaller breeds tend to dream more frequently than larger ones."],
16
+ ["woof", "Did someone say woof? You speak my language."],
17
+ ["good boi", "That would be me, obviously."],
18
+ ["walk", "Did anybody say walk? I'm in."],
19
+ ];
20
+
21
+ const FALLBACKS = [
22
+ "I don't know, but I'd bet a dog would!",
23
+ "Good question! I need more dog facts for that one.",
24
+ "I'm not sure about that, but dogs are great!",
25
+ ];
26
+
27
+ const SUGGESTIONS = [
28
+ "Tell me about border collies",
29
+ "How good is a dog's sense of smell?",
30
+ "Do dogs dream?",
31
+ "Why is dental care important?",
32
+ "What's the fastest dog?",
33
+ "How long have dogs been domesticated?",
34
+ "Do all dogs bark?",
35
+ "Is my dog bored?",
36
+ ];
37
+
38
+ const INITIAL_GREETING = "Hi there! Ask me something simple, ideally about dogs.";
39
+
40
+ function retrieveFact(query) {
41
+ const q = (query || "").toLowerCase();
42
+ for (const [keyword, fact] of KNOWLEDGE_BASE) {
43
+ if (q.includes(keyword)) return { keyword, fact };
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function pickFallback() {
49
+ return FALLBACKS[Math.floor(Math.random() * FALLBACKS.length)];
50
+ }
51
+
52
+ // Build a BlenderBot-style prompt so the "claude" backend produces responses
53
+ // in the same voice the Python reference targeted.
54
+ function buildPrompt(history, userInput, fact, personality) {
55
+ const lastFour = history.slice(-4);
56
+ const lines = lastFour.map(m => `${m.role === "user" ? "User" : "Bot"}: ${m.content}`);
57
+ if (fact) lines.unshift(fact.fact);
58
+ lines.push(`User: ${userInput}`);
59
+ const toneByLevel = {
60
+ subtle: "Keep the dog references light; answer the question plainly.",
61
+ medium: "Be warm and playful, with a clear dog-lover streak.",
62
+ loud: "Go full dog mode: enthusiastic, tail-wagging, lots of 'woof' energy.",
63
+ };
64
+ const tone = toneByLevel[personality] || toneByLevel.subtle;
65
+ return (
66
+ "You are Fetch, a friendly dog-loving chatbot. "
67
+ + "Answer in 1–2 short sentences. "
68
+ + tone + " "
69
+ + "If a fact is provided above the conversation, weave it naturally into your reply.\n\n"
70
+ + lines.join("\n")
71
+ + "\nBot:"
72
+ );
73
+ }
74
+
75
+ async function generateReply({ userInput, history, backend, personality }) {
76
+ const fact = retrieveFact(userInput);
77
+
78
+ if (backend === "claude" && typeof window !== "undefined" && window.claude?.complete) {
79
+ try {
80
+ const prompt = buildPrompt(history, userInput, fact, personality);
81
+ const raw = await window.claude.complete(prompt);
82
+ const text = (raw || "").trim().replace(/^Bot:\s*/i, "").split("\nUser:")[0].trim();
83
+ if (!text || text.split(/\s+/).length <= 2) {
84
+ return { text: pickFallback(), fact };
85
+ }
86
+ return { text, fact };
87
+ } catch (err) {
88
+ console.warn("Fetch: claude backend failed, falling back.", err);
89
+ // fall through to local path
90
+ }
91
+ }
92
+
93
+ // Local / offline / HF-static path: keyword fact wins, else fallback.
94
+ if (fact) return { text: fact.fact, fact };
95
+ return { text: pickFallback(), fact };
96
+ }
97
+
98
+ // Hook: owns conversation state + send/clear + rotating suggestions.
99
+ function useFetchChat({ backend = "claude", personality = "subtle" } = {}) {
100
+ const [messages, setMessages] = React.useState(() => [
101
+ { id: "m0", role: "assistant", content: INITIAL_GREETING, ts: Date.now() },
102
+ ]);
103
+ const [pending, setPending] = React.useState(false);
104
+ const [debug, setDebug] = React.useState([]);
105
+ const seq = React.useRef(1);
106
+
107
+ const nextId = () => `m${seq.current++}`;
108
+
109
+ const send = React.useCallback(async (rawInput) => {
110
+ const userInput = (rawInput || "").trim();
111
+ if (!userInput || pending) return;
112
+
113
+ const userMsg = { id: nextId(), role: "user", content: userInput, ts: Date.now() };
114
+ const placeholderId = nextId();
115
+ const placeholder = { id: placeholderId, role: "assistant", content: "…", pending: true, ts: Date.now() };
116
+
117
+ // Snapshot history BEFORE adding the new user message — that's what the
118
+ // prompt builder should see as prior context.
119
+ let priorHistory = [];
120
+ setMessages(prev => { priorHistory = prev; return [...prev, userMsg, placeholder]; });
121
+ setPending(true);
122
+
123
+ const t0 = performance.now();
124
+ const { text, fact } = await generateReply({
125
+ userInput,
126
+ history: priorHistory,
127
+ backend,
128
+ personality,
129
+ });
130
+ const ms = Math.round(performance.now() - t0);
131
+
132
+ setMessages(prev => prev.map(m =>
133
+ m.id === placeholderId
134
+ ? { ...m, content: text, pending: false, fact, ts: Date.now() }
135
+ : m
136
+ ));
137
+ setDebug(prev => [
138
+ { t: Date.now(), user: userInput, reply: text, fact, ms, backend },
139
+ ...prev,
140
+ ].slice(0, 20));
141
+ setPending(false);
142
+ }, [backend, personality, pending]);
143
+
144
+ const reset = React.useCallback(() => {
145
+ seq.current = 1;
146
+ setMessages([{ id: "m0", role: "assistant", content: INITIAL_GREETING, ts: Date.now() }]);
147
+ setDebug([]);
148
+ }, []);
149
+
150
+ return { messages, pending, debug, send, reset };
151
+ }
152
+
153
+ // Rotating suggestion carousel — 3 chips visible, rotates on interval.
154
+ function useRotatingSuggestions({ visible = 3, intervalMs = 4200 } = {}) {
155
+ const [offset, setOffset] = React.useState(0);
156
+ React.useEffect(() => {
157
+ const id = setInterval(() => setOffset(o => (o + 1) % SUGGESTIONS.length), intervalMs);
158
+ return () => clearInterval(id);
159
+ }, [intervalMs]);
160
+ const current = React.useMemo(() => {
161
+ const out = [];
162
+ for (let i = 0; i < visible; i++) out.push(SUGGESTIONS[(offset + i) % SUGGESTIONS.length]);
163
+ return out;
164
+ }, [offset, visible]);
165
+ return current;
166
+ }
167
+
168
+ Object.assign(window, {
169
+ useFetchChat,
170
+ useRotatingSuggestions,
171
+ retrieveFact,
172
+ FETCH_KNOWLEDGE_BASE: KNOWLEDGE_BASE,
173
+ FETCH_SUGGESTIONS: SUGGESTIONS,
174
+ });
debug-panel.jsx ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // debug-panel.jsx — floating developer panel shared across all variants.
2
+ // Surfaces: recent exchanges, fact-match status, backend, latency, and the
3
+ // full keyword-lookup table from the Python reference.
4
+
5
+ function DebugPanel({ debug, onClose, palette, font }) {
6
+ const [tab, setTab] = React.useState("log");
7
+ return (
8
+ <div style={{
9
+ position: "absolute", right: 16, bottom: 16, top: 16,
10
+ width: 380, maxWidth: "44vw",
11
+ background: palette.bg, color: palette.fg,
12
+ border: `1px solid ${palette.muted}`,
13
+ borderRadius: 10,
14
+ fontFamily: font, fontSize: 11,
15
+ display: "flex", flexDirection: "column",
16
+ boxShadow: "0 10px 30px rgba(0,0,0,.35)",
17
+ zIndex: 50,
18
+ overflow: "hidden",
19
+ }}>
20
+ <div style={{
21
+ padding: "8px 12px",
22
+ borderBottom: `1px solid ${palette.muted}`,
23
+ display: "flex", alignItems: "center", justifyContent: "space-between",
24
+ }}>
25
+ <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
26
+ <span style={{ color: palette.accent }}>●</span>
27
+ <span style={{ letterSpacing: 0.8, textTransform: "uppercase", fontSize: 10 }}>
28
+ Fetch / devtools
29
+ </span>
30
+ </div>
31
+ <button onClick={onClose} aria-label="Close debug panel" style={{
32
+ background: "transparent", border: "none", color: palette.dim,
33
+ cursor: "pointer", fontSize: 14, padding: "0 4px",
34
+ }}>×</button>
35
+ </div>
36
+
37
+ <div style={{
38
+ display: "flex", gap: 2,
39
+ padding: "6px 10px 0",
40
+ borderBottom: `1px solid ${palette.muted}`,
41
+ }}>
42
+ {[
43
+ { id: "log", label: "Exchanges" },
44
+ { id: "kb", label: "Keyword table" },
45
+ { id: "about", label: "About" },
46
+ ].map(t => (
47
+ <button key={t.id} onClick={() => setTab(t.id)} style={{
48
+ background: "transparent",
49
+ color: tab === t.id ? palette.fg : palette.dim,
50
+ borderBottom: tab === t.id ? `2px solid ${palette.accent}` : "2px solid transparent",
51
+ border: "none", borderRadius: 0,
52
+ padding: "6px 8px",
53
+ fontFamily: "inherit", fontSize: 11, cursor: "pointer",
54
+ }}>{t.label}</button>
55
+ ))}
56
+ </div>
57
+
58
+ <div style={{ flex: 1, overflowY: "auto", padding: 12 }}>
59
+ {tab === "log" && <DebugLog debug={debug} palette={palette} />}
60
+ {tab === "kb" && <DebugKB palette={palette} />}
61
+ {tab === "about" && <DebugAbout palette={palette} />}
62
+ </div>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ function DebugLog({ debug, palette }) {
68
+ if (debug.length === 0) {
69
+ return (
70
+ <div style={{ color: palette.dim, fontStyle: "italic", padding: "8px 4px" }}>
71
+ No exchanges yet. Ask Fetch something.
72
+ </div>
73
+ );
74
+ }
75
+ return (
76
+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
77
+ {debug.map((d, i) => (
78
+ <div key={i} style={{
79
+ border: `1px solid ${palette.muted}`,
80
+ borderRadius: 6,
81
+ padding: 8,
82
+ }}>
83
+ <div style={{ color: palette.dim, fontSize: 10, display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
84
+ <span>{new Date(d.t).toLocaleTimeString()}</span>
85
+ <span>{d.backend} · {d.ms}ms</span>
86
+ </div>
87
+ <div style={{ marginBottom: 4 }}>
88
+ <span style={{ color: palette.dim }}>user&gt; </span>
89
+ {d.user}
90
+ </div>
91
+ <div style={{ marginBottom: 4 }}>
92
+ <span style={{ color: palette.accent }}>reply&gt; </span>
93
+ {d.reply}
94
+ </div>
95
+ <div style={{ color: palette.dim, fontSize: 10 }}>
96
+ fact: {d.fact ? <span style={{ color: palette.accent }}>matched "{d.fact.keyword}"</span> : "none"}
97
+ </div>
98
+ </div>
99
+ ))}
100
+ </div>
101
+ );
102
+ }
103
+
104
+ function DebugKB({ palette }) {
105
+ return (
106
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
107
+ <div style={{ color: palette.dim, marginBottom: 4 }}>
108
+ {FETCH_KNOWLEDGE_BASE.length} keywords · first match wins
109
+ </div>
110
+ {FETCH_KNOWLEDGE_BASE.map(([k, v]) => (
111
+ <div key={k} style={{
112
+ border: `1px solid ${palette.muted}`,
113
+ borderRadius: 6,
114
+ padding: 8,
115
+ }}>
116
+ <div style={{ color: palette.accent, marginBottom: 3 }}>{k}</div>
117
+ <div style={{ color: palette.fg, lineHeight: 1.4 }}>{v}</div>
118
+ </div>
119
+ ))}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ function DebugAbout({ palette }) {
125
+ return (
126
+ <div style={{ lineHeight: 1.6, color: palette.fg }}>
127
+ <div style={{ color: palette.dim, marginBottom: 8 }}>Fetch — a friendly dog-loving chatbot.</div>
128
+ <ul style={{ margin: 0, paddingLeft: 16, display: "flex", flexDirection: "column", gap: 4 }}>
129
+ <li>Reference model: BlenderBot-400M-distill</li>
130
+ <li>Context window: last 4 turns</li>
131
+ <li>Keyword lookup: first match prepended to the prompt</li>
132
+ <li>Fallbacks: 3 canned replies when generation is empty</li>
133
+ <li>Preview backend: window.claude.complete (Haiku 4.5)</li>
134
+ <li>HF Static Space fallback: keyword lookup + fallbacks only</li>
135
+ </ul>
136
+ <div style={{ marginTop: 10, color: palette.dim, fontSize: 10 }}>
137
+ Shortcuts: ⌘K clear · ⌘D toggle this panel
138
+ </div>
139
+ </div>
140
+ );
141
+ }
142
+
143
+ Object.assign(window, { DebugPanel });
index.html CHANGED
@@ -1,19 +1,65 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>This is Fetch</title>
7
+ <meta name="description" content="A friendly chatbot with a soft spot for dogs." />
8
+
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
12
+
13
+ <style>
14
+ html, body { margin: 0; padding: 0; background: #0a0f0a; color: #c8f7c5; height: 100%; }
15
+ body { font-family: "JetBrains Mono", ui-monospace, monospace; overflow: hidden; }
16
+ #root { position: fixed; inset: 0; }
17
+
18
+ @keyframes fetch-fadein {
19
+ from { opacity: 0; transform: translateY(4px); }
20
+ to { opacity: 1; transform: translateY(0); }
21
+ }
22
+ @keyframes fetch-blink {
23
+ 0%, 49% { opacity: 0.8; }
24
+ 50%, 100% { opacity: 0; }
25
+ }
26
+ .fetch-blink { animation: fetch-blink 1s steps(2) infinite; }
27
+ </style>
28
+
29
+ <script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
30
+ <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
31
+ <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
32
+ </head>
33
+ <body>
34
+
35
+ <div id="root"></div>
36
+
37
+ <script type="text/babel" src="chat-engine.jsx"></script>
38
+ <script type="text/babel" src="debug-panel.jsx"></script>
39
+ <script type="text/babel" src="variant-terminal.jsx"></script>
40
+
41
+ <script type="text/babel" data-presets="react">
42
+ // HF Static Space build: terminal variant only, local backend (keyword
43
+ // lookup + fallbacks — no network calls). The "claude" backend is
44
+ // unavailable outside the preview host, so this build pins backend="local".
45
+ const FONT_PAIR = {
46
+ mono: '"JetBrains Mono", ui-monospace, monospace',
47
+ };
48
+
49
+ function FetchApp() {
50
+ return (
51
+ <TerminalVariant
52
+ backend="local"
53
+ personality="subtle"
54
+ density="comfortable"
55
+ accent="green"
56
+ fontPair={FONT_PAIR}
57
+ />
58
+ );
59
+ }
60
+
61
+ ReactDOM.createRoot(document.getElementById("root")).render(<FetchApp />);
62
+ </script>
63
+
64
+ </body>
65
  </html>
variant-terminal.jsx ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // variant-terminal.jsx — Fetch as a retro CLI dog.
2
+ // Full-bleed terminal: `fetch>` prompt, you$ / dog# prefixes, facts shown as
3
+ // dimmed `// fact:` comment lines. Suggestions rotate as tips at bottom.
4
+
5
+ const termPalettes = {
6
+ green: { bg: "#0a0f0a", fg: "#c8f7c5", dim: "#6b8a6b", accent: "#7dfc82", muted: "#3a4a3a" },
7
+ amber: { bg: "#120d05", fg: "#f3d488", dim: "#8a7548", accent: "#ffbf47", muted: "#4a3a1a" },
8
+ mono: { bg: "#0b0b0c", fg: "#e6e6e6", dim: "#7a7a7a", accent: "#ffffff", muted: "#3a3a3a" },
9
+ };
10
+
11
+ function TerminalVariant({ backend, personality, density, accent, fontPair, bubble }) {
12
+ // bubble shape doesn't apply here; density affects line-height.
13
+ const { messages, pending, debug, send, reset } = useFetchChat({ backend, personality });
14
+ const suggestions = useRotatingSuggestions({ visible: 3 });
15
+ const [input, setInput] = React.useState("");
16
+ const [showDebug, setShowDebug] = React.useState(false);
17
+ const scrollRef = React.useRef(null);
18
+ const inputRef = React.useRef(null);
19
+
20
+ const palette = termPalettes[accent] || termPalettes.green;
21
+
22
+ const lineHeight = density === "compact" ? 1.35 : density === "cozy" ? 1.75 : 1.55;
23
+ const pad = density === "compact" ? 16 : density === "cozy" ? 32 : 24;
24
+
25
+ React.useEffect(() => {
26
+ const el = scrollRef.current;
27
+ if (el) el.scrollTop = el.scrollHeight;
28
+ }, [messages.length, pending]);
29
+
30
+ React.useEffect(() => {
31
+ const onKey = (e) => {
32
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "d") {
33
+ e.preventDefault();
34
+ setShowDebug(s => !s);
35
+ }
36
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
37
+ e.preventDefault();
38
+ reset();
39
+ }
40
+ };
41
+ window.addEventListener("keydown", onKey);
42
+ return () => window.removeEventListener("keydown", onKey);
43
+ }, [reset]);
44
+
45
+ const submit = (e) => {
46
+ e?.preventDefault();
47
+ const v = input.trim();
48
+ if (!v || pending) return;
49
+ setInput("");
50
+ send(v);
51
+ };
52
+
53
+ const quickSend = (text) => {
54
+ if (pending) return;
55
+ send(text);
56
+ };
57
+
58
+ return (
59
+ <div style={{
60
+ position: "absolute", inset: 0,
61
+ background: palette.bg, color: palette.fg,
62
+ fontFamily: fontPair.mono,
63
+ fontSize: 14, lineHeight,
64
+ display: "flex", flexDirection: "column",
65
+ overflow: "hidden",
66
+ }}>
67
+ <header style={{
68
+ display: "flex", alignItems: "center", justifyContent: "space-between",
69
+ padding: `10px ${pad}px`,
70
+ borderBottom: `1px solid ${palette.muted}`,
71
+ color: palette.dim, fontSize: 12,
72
+ }}>
73
+ <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
74
+ <span style={{ color: palette.accent }}>◉</span>
75
+ <span>fetch — blenderbot-400M-distill — 4-turn context</span>
76
+ </div>
77
+ <div style={{ display: "flex", gap: 16 }}>
78
+ <button onClick={reset} style={termBtn(palette)}>clear ⌘K</button>
79
+ <button onClick={() => setShowDebug(s => !s)} style={termBtn(palette)}>
80
+ debug ⌘D
81
+ </button>
82
+ </div>
83
+ </header>
84
+
85
+ <div ref={scrollRef} style={{
86
+ flex: 1, overflowY: "auto",
87
+ padding: `${pad}px ${pad * 1.5}px`,
88
+ scrollbarWidth: "thin", scrollbarColor: `${palette.muted} transparent`,
89
+ }}>
90
+ <pre style={{
91
+ margin: 0, fontFamily: "inherit", color: palette.dim,
92
+ whiteSpace: "pre-wrap", marginBottom: 16,
93
+ }}>
94
+ {`// this is fetch — a friendly dog-loving chatbot
95
+ // type a question and hit enter. try "tell me about border collies".
96
+ // shortcuts: ⌘K clear · ⌘D debug
97
+ `}
98
+ </pre>
99
+
100
+ {messages.filter(m => !m.pending).map((m) => (
101
+ <TerminalLine
102
+ key={m.id}
103
+ msg={m}
104
+ palette={palette}
105
+ />
106
+ ))}
107
+ {pending && <BlinkingCursor palette={palette} label="fetching an answer" />}
108
+ </div>
109
+
110
+ <form onSubmit={submit} style={{
111
+ padding: `${pad - 4}px ${pad * 1.5}px ${pad}px`,
112
+ borderTop: `1px solid ${palette.muted}`,
113
+ display: "flex", flexDirection: "column", gap: 10,
114
+ }}>
115
+ <SuggestionsRow
116
+ suggestions={suggestions}
117
+ onPick={quickSend}
118
+ palette={palette}
119
+ pending={pending}
120
+ />
121
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
122
+ <span style={{ color: palette.accent, userSelect: "none" }}>fetch&gt;</span>
123
+ <input
124
+ ref={inputRef}
125
+ value={input}
126
+ onChange={e => setInput(e.target.value)}
127
+ placeholder={pending ? "…waiting on dog" : "ask something"}
128
+ autoFocus
129
+ disabled={pending}
130
+ style={{
131
+ flex: 1, background: "transparent", border: "none", outline: "none",
132
+ color: palette.fg, fontFamily: "inherit", fontSize: 14, caretColor: palette.accent,
133
+ }}
134
+ />
135
+ <button type="submit" disabled={pending || !input.trim()} style={{
136
+ ...termBtn(palette),
137
+ color: palette.accent, borderColor: palette.accent,
138
+ opacity: pending || !input.trim() ? 0.4 : 1,
139
+ }}>↵ send</button>
140
+ </div>
141
+ </form>
142
+
143
+ {showDebug && (
144
+ <DebugPanel
145
+ debug={debug}
146
+ onClose={() => setShowDebug(false)}
147
+ palette={{ bg: palette.bg, fg: palette.fg, dim: palette.dim, accent: palette.accent, muted: palette.muted }}
148
+ font={fontPair.mono}
149
+ />
150
+ )}
151
+ </div>
152
+ );
153
+ }
154
+
155
+ function termBtn(p) {
156
+ return {
157
+ background: "transparent",
158
+ color: p.dim,
159
+ border: `1px solid ${p.muted}`,
160
+ padding: "3px 8px",
161
+ borderRadius: 4,
162
+ fontFamily: "inherit",
163
+ fontSize: 11,
164
+ cursor: "pointer",
165
+ letterSpacing: 0.3,
166
+ };
167
+ }
168
+
169
+ function TerminalLine({ msg, palette }) {
170
+ const isUser = msg.role === "user";
171
+ const prefix = isUser ? "you$" : "dog#";
172
+ const color = isUser ? palette.fg : palette.accent;
173
+ const [mounted, setMounted] = React.useState(false);
174
+ React.useEffect(() => {
175
+ const id = requestAnimationFrame(() => setMounted(true));
176
+ return () => cancelAnimationFrame(id);
177
+ }, []);
178
+ return (
179
+ <div style={{
180
+ marginBottom: 10,
181
+ opacity: mounted ? 1 : 0,
182
+ transform: mounted ? "translateY(0)" : "translateY(4px)",
183
+ transition: "opacity .18s ease, transform .18s ease",
184
+ }}>
185
+ {!isUser && msg.fact && !msg.pending && (
186
+ <div style={{ color: palette.dim, fontStyle: "italic", marginBottom: 2 }}>
187
+ {`// fact matched: "${msg.fact.keyword}"`}
188
+ </div>
189
+ )}
190
+ <div>
191
+ <span style={{ color: palette.dim, userSelect: "none" }}>{prefix} </span>
192
+ <span style={{ color, whiteSpace: "pre-wrap" }}>
193
+ {msg.pending ? <DotDotDot palette={palette} /> : msg.content}
194
+ </span>
195
+ </div>
196
+ </div>
197
+ );
198
+ }
199
+
200
+ function DotDotDot({ palette }) {
201
+ const [n, setN] = React.useState(1);
202
+ React.useEffect(() => {
203
+ const id = setInterval(() => setN(x => (x % 3) + 1), 380);
204
+ return () => clearInterval(id);
205
+ }, []);
206
+ return <span style={{ color: palette.dim }}>{".".repeat(n)}</span>;
207
+ }
208
+
209
+ function BlinkingCursor({ palette, label }) {
210
+ return (
211
+ <div style={{ color: palette.dim, fontStyle: "italic", marginTop: 4 }}>
212
+ {label}
213
+ <span className="fetch-blink" style={{
214
+ display: "inline-block", width: 8, height: 14, marginLeft: 6,
215
+ verticalAlign: "middle", background: palette.accent, opacity: 0.7,
216
+ }}/>
217
+ </div>
218
+ );
219
+ }
220
+
221
+ function SuggestionsRow({ suggestions, onPick, palette, pending }) {
222
+ return (
223
+ <div style={{
224
+ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center",
225
+ color: palette.dim, fontSize: 11,
226
+ }}>
227
+ <span style={{ opacity: 0.7 }}>try:</span>
228
+ {suggestions.map((s, i) => (
229
+ <button
230
+ key={s}
231
+ type="button"
232
+ disabled={pending}
233
+ onClick={() => onPick(s)}
234
+ style={{
235
+ ...termBtn(palette),
236
+ padding: "2px 8px",
237
+ fontSize: 11,
238
+ cursor: pending ? "default" : "pointer",
239
+ opacity: pending ? 0.5 : 1,
240
+ animation: `fetch-fadein .4s ease ${i * 60}ms both`,
241
+ }}
242
+ >{s}</button>
243
+ ))}
244
+ </div>
245
+ );
246
+ }
247
+
248
+ Object.assign(window, { TerminalVariant });