kith-ai / frontend.py
yooi's picture
Deploy Kith — relationship memory on small open models (ZeroGPU)
30ded6d verified
Raw
History Blame Contribute Delete
35 kB
"""Kith Local — the hand-written Today screen (no default Gradio UI).
Served verbatim at "/" by gr.Server. The page records or accepts an audio
file, decodes + resamples it to 16 kHz mono WAV in the browser (so the
server never needs ffmpeg), then walks the loop:
transcribe -> ingest (MiniCPM extraction -> SQLite) -> today feed.
Layout is a responsive app-shell: a left rail (capture + conversation
history) beside the Today feed on desktop, collapsing to a single column on
phones. Same markup, re-mapped via CSS grid areas at one breakpoint.
"""
FRONTEND_HTML = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kith — for you today</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23E07A5F' d='M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'/%3E%3C/svg%3E">
<meta property="og:title" content="Kith — be the friend who remembers">
<meta property="og:description" content="Record a conversation; Kith surfaces the thoughtful thing to do today — a birthday, a promise you owe, someone to reconnect with. Small open models, a memory that's yours.">
<meta property="og:image" content="https://huggingface.co/spaces/build-small-hackathon/kith-ai/resolve/main/assets/brand-images/kith-ai-brand-1.jpeg">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://huggingface.co/spaces/build-small-hackathon/kith-ai/resolve/main/assets/brand-images/kith-ai-brand-1.jpeg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT@0,9..144,300..700,50;1,9..144,300..700,50&family=Inter:ital,opsz,wght@0,14..32,300..650;1,14..32,300..650&display=swap" rel="stylesheet">
<style>
:root {
--cream:#FBF8F3; --panel:#FFFEFB; --ink:#1C1B19; --muted:#7A736A;
--terracotta:#E07A5F; --terracotta-deep:#C75F45; --amber:#B07D2B;
--sage:#7C8A6E; --violet:#8C7BA6; --line:rgba(28,27,25,.09);
--serif:"Fraunces", Georgia, serif;
--sans:"Inter", ui-sans-serif, system-ui, sans-serif;
}
* { box-sizing:border-box; }
html, body { margin:0; min-height:100%; }
body {
background:
radial-gradient(1200px 600px at 50% -10%, rgba(224,122,95,.07), transparent 60%),
radial-gradient(900px 500px at 110% 110%, rgba(124,138,110,.06), transparent 55%),
var(--cream);
color:var(--ink);
font-family:var(--sans);
font-size:15px; line-height:1.55;
-webkit-font-smoothing:antialiased;
}
/* paper grain */
body::before {
content:""; position:fixed; inset:0; pointer-events:none; z-index:1; opacity:.05;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* ---- app shell: stacked on phones, rail + main on desktop ---- */
.shell {
position:relative; z-index:2; max-width:1180px; margin:0 auto;
padding:30px 22px 70px; display:grid; gap:22px;
grid-template-columns:1fr;
grid-template-areas: "brand" "capture" "main" "history";
}
.brand-area { grid-area:brand; }
.capture-area { grid-area:capture; }
.history-area { grid-area:history; }
.main-area { grid-area:main; min-width:0; }
@media (min-width:900px) {
.shell {
gap:26px 40px;
grid-template-columns:320px minmax(0,1fr);
grid-template-areas:
"brand main"
"capture main"
"history main";
align-content:start;
}
/* the rail floats with the page; main scrolls beside it */
.capture-area, .history-area { position:sticky; }
}
.wordmark {
font-family:var(--serif); font-weight:600; font-size:15px; letter-spacing:.16em;
text-transform:uppercase; color:var(--terracotta-deep);
display:flex; align-items:center; gap:8px;
}
.wordmark::after { content:""; height:1px; flex:1; background:var(--line); }
/* main header */
h1 {
font-family:var(--serif); font-variation-settings:"SOFT" 50, "opsz" 60;
font-weight:430; font-size:clamp(36px, 5vw, 52px); line-height:1.05; letter-spacing:-.01em;
margin:4px 0 6px;
}
.date { color:var(--muted); font-size:14px; margin:0 0 22px; }
/* ritual: the record control (lives in the rail) */
.ritual {
background:var(--panel); border:1px solid var(--line); border-radius:20px;
padding:22px 20px 18px; box-shadow:0 1px 2px rgba(28,27,25,.04), 0 12px 32px -18px rgba(28,27,25,.18);
}
.ritual-row { display:flex; align-items:center; gap:16px; }
#recordBtn {
width:62px; height:62px; min-width:62px; border-radius:50%; border:none; cursor:pointer;
background:radial-gradient(circle at 32% 28%, #EE9479, var(--terracotta) 58%, var(--terracotta-deep));
box-shadow:0 6px 18px -6px rgba(199,95,69,.55), inset 0 1px 1px rgba(255,255,255,.45);
display:grid; place-items:center; transition:transform .15s ease;
}
#recordBtn:hover { transform:scale(1.04); }
#recordBtn:disabled { opacity:.45; cursor:not-allowed; transform:none; }
#recordBtn svg { width:23px; height:23px; fill:#FFF7F2; }
#recordBtn.recording { animation:pulse 1.6s ease-in-out infinite; }
@keyframes pulse {
0%,100% { box-shadow:0 6px 18px -6px rgba(199,95,69,.55), 0 0 0 0 rgba(224,122,95,.35); }
50% { box-shadow:0 6px 18px -6px rgba(199,95,69,.55), 0 0 0 14px rgba(224,122,95,0); }
}
.ritual-copy b { font-weight:560; display:block; font-size:15px; }
.ritual-copy span { color:var(--muted); font-size:13px; }
.alt-sources { display:flex; flex-wrap:wrap; gap:16px; margin-top:16px; padding-top:14px; border-top:1px dashed var(--line); }
.alt-sources a { color:var(--terracotta-deep); font-size:13px; font-weight:520; text-decoration:none; cursor:pointer; }
.alt-sources a:hover { text-decoration:underline; }
#fileInput { display:none; }
/* curated demo scenarios */
.scenarios { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin-top:12px; }
.scenarios-label { font-size:12px; color:var(--muted); margin-right:2px; }
.chip {
font-family:inherit; font-size:12.5px; color:var(--terracotta-deep);
background:#FBEFEA; border:1px solid rgba(199,95,69,.22); border-radius:999px;
padding:5px 12px; cursor:pointer; transition:background .15s ease, transform .1s ease;
}
.chip:hover { background:#F6E2DA; }
.chip:active { transform:scale(.97); }
.chip:disabled { opacity:.5; cursor:not-allowed; }
/* ---- conversation history (rail) ---- */
.history {
background:var(--panel); border:1px solid var(--line); border-radius:20px;
padding:16px 8px 10px; box-shadow:0 1px 2px rgba(28,27,25,.04), 0 12px 32px -20px rgba(28,27,25,.16);
}
.history-head {
font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted);
font-weight:560; margin:2px 0 8px; padding:0 12px;
display:flex; align-items:center; justify-content:space-between;
}
.history-head .n { color:var(--terracotta-deep); }
#historyList { list-style:none; margin:0; padding:0; max-height:46vh; overflow-y:auto; }
.conv {
padding:10px 12px; border-radius:12px; cursor:pointer;
transition:background .15s ease; border:1px solid transparent;
}
.conv:hover { background:var(--cream); border-color:var(--line); }
.conv-top { display:flex; align-items:baseline; justify-content:space-between; gap:8px; }
.conv-title { font-weight:560; font-size:14px; color:var(--ink); }
.conv-date { font-size:11.5px; color:var(--muted); white-space:nowrap; }
.conv-counts { font-size:12px; color:var(--muted); margin-top:2px; }
.history-empty { padding:6px 12px 12px; font-size:12.5px; color:var(--muted); font-style:italic; }
/* progress steps — a live checklist with spinners + elapsed timers */
#steps {
margin:0 0 22px; display:none; padding:14px 18px; border-radius:16px;
background:linear-gradient(180deg, #FFFEFB, #FDFAF4); border:1px solid var(--line);
}
#steps .steps-head {
font-size:11.5px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted);
font-weight:560; margin:0 0 8px;
}
.step {
display:flex; align-items:center; gap:11px; font-size:13.5px; min-height:30px;
color:var(--muted); opacity:.55; transition:color .2s ease, opacity .2s ease;
}
.step .ico { width:18px; height:18px; flex:none; display:grid; place-items:center; }
.step .ico::before { content:""; width:7px; height:7px; border-radius:50%; border:1.5px solid var(--line); }
.step.active { color:var(--ink); opacity:1; font-weight:540; }
.step.active .ico::before { display:none; }
.step.active .ico::after {
content:""; width:15px; height:15px; border-radius:50%;
border:2px solid rgba(224,122,95,.25); border-top-color:var(--terracotta);
animation:spin .7s linear infinite;
}
@keyframes spin { to { transform:rotate(360deg); } }
.step.done { color:#4A453C; opacity:1; }
.step.done .ico::before { display:none; }
.step.done .ico::after {
content:"✓"; color:#fff; font-size:10px; font-weight:700;
width:16px; height:16px; border-radius:50%; background:var(--sage);
display:grid; place-items:center;
}
.step .elapsed {
margin-left:auto; font-size:12px; color:var(--muted); font-variant-numeric:tabular-nums;
opacity:0; transition:opacity .2s ease;
}
.step.active .elapsed, .step.done .elapsed { opacity:.65; }
#steps.finished { animation:fadeOutSteps .5s ease 1.1s forwards; }
@keyframes fadeOutSteps { to { opacity:0; height:0; padding-top:0; padding-bottom:0; margin:0; border-width:0; } }
/* transcript keepsake */
#transcriptBox { display:none; margin-bottom:24px; }
.label {
font-size:11.5px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted);
margin:0 0 8px 2px; font-weight:560;
}
.transcript {
font-family:var(--serif); font-variation-settings:"SOFT" 70;
font-weight:380; font-size:16.5px; line-height:1.6; color:#3A362F;
background:linear-gradient(180deg, #FFFEFB, #FDFAF4);
border:1px solid var(--line); border-radius:16px; padding:16px 18px;
max-height:200px; overflow-y:auto; white-space:pre-wrap;
}
/* feed — single column on phone, 2-up grid when wide */
#feed { display:grid; gap:16px; grid-template-columns:1fr; align-items:start; }
@media (min-width:1180px) { #feed { grid-template-columns:1fr 1fr; } }
.card {
background:var(--panel); border:1px solid var(--line); border-radius:20px;
padding:18px 20px 16px; position:relative; overflow:hidden;
box-shadow:0 1px 2px rgba(28,27,25,.04), 0 14px 36px -20px rgba(28,27,25,.22);
opacity:0; transform:translateY(14px);
animation:rise .6s cubic-bezier(.22,.8,.3,1) forwards;
}
@keyframes rise { to { opacity:1; transform:none; } }
.card::before {
content:""; position:absolute; left:0; top:14px; bottom:14px; width:3px; border-radius:0 3px 3px 0;
background:var(--kind, var(--terracotta));
}
.card .kind {
font-size:11px; letter-spacing:.15em; text-transform:uppercase; font-weight:600;
color:var(--kind, var(--terracotta));
}
.card h3 {
font-family:var(--serif); font-variation-settings:"SOFT" 40;
font-weight:480; font-size:19.5px; line-height:1.3; margin:7px 0 6px;
}
.card .why { color:var(--muted); font-size:13.5px; margin:0; }
.card .person {
float:right; font-family:var(--serif); font-style:italic; font-weight:420;
color:var(--kind, var(--terracotta)); font-size:13.5px; margin-left:10px;
}
.card.kind-thoughtful { --kind:var(--terracotta-deep); }
.card.kind-owed { --kind:var(--amber); }
.card.kind-reconnect { --kind:var(--sage); }
.card.kind-remember { --kind:var(--violet); }
.card .dismiss {
position:absolute; top:12px; right:12px; width:26px; height:26px; border:none;
border-radius:50%; background:transparent; color:var(--muted); cursor:pointer;
font-size:17px; line-height:1; display:grid; place-items:center; padding:0;
opacity:.35; transition:opacity .15s ease, background .15s ease, color .15s ease;
}
.card:hover .dismiss { opacity:.7; }
.card .dismiss:hover { opacity:1; background:rgba(199,95,69,.1); color:var(--terracotta-deep); }
.card.leaving { animation:fall .32s cubic-bezier(.4,0,1,1) forwards; }
@keyframes fall { to { opacity:0; transform:translateY(-8px) scale(.97); max-height:0; padding-top:0; padding-bottom:0; } }
.card.clickable { cursor:pointer; }
.card.clickable:hover { box-shadow:0 1px 2px rgba(28,27,25,.05), 0 18px 42px -20px rgba(28,27,25,.30); }
.card .chev {
display:inline-block; margin-left:6px; font-size:11px; color:var(--muted);
transition:transform .2s ease; transform:translateY(-1px);
}
.card.open .chev { transform:rotate(180deg) translateY(1px); }
.detail {
overflow:hidden; max-height:0; opacity:0;
transition:max-height .3s ease, opacity .25s ease, margin-top .3s ease;
}
.card.open .detail { max-height:600px; opacity:1; margin-top:14px; }
.detail-inner { padding-top:14px; border-top:1px dashed var(--line); }
.detail .quote {
font-family:var(--serif); font-style:italic; font-size:14.5px; line-height:1.5;
color:#4A453C; padding:2px 0 2px 13px; border-left:2px solid var(--kind, var(--terracotta)); margin:0;
}
.detail .quote-src { font-size:11.5px; color:var(--muted); font-style:normal; margin-top:5px; }
.detail .no-quote { font-size:13px; color:var(--muted); font-style:italic; margin:0; }
.detail .mem-head {
font-size:11px; letter-spacing:.13em; text-transform:uppercase; color:var(--muted);
font-weight:560; margin:15px 0 8px;
}
.mem-list { list-style:none; margin:0; padding:0; }
.mem-list li { display:flex; gap:9px; align-items:baseline; font-size:13.5px; color:#4A453C; padding:3px 0; }
.mem-list li .tag {
flex:none; font-size:9.5px; letter-spacing:.08em; text-transform:uppercase; font-weight:600;
color:var(--muted); background:var(--cream); border:1px solid var(--line);
border-radius:6px; padding:2px 6px; margin-top:1px; min-width:54px; text-align:center;
}
.mem-list li .when { margin-left:auto; font-size:12px; color:var(--muted); white-space:nowrap; }
.detail .meta { font-size:12px; color:var(--muted); margin:12px 0 0; }
.empty {
grid-column:1 / -1; text-align:center; color:var(--muted); font-size:13.5px; padding:30px 22px;
border:1px dashed var(--line); border-radius:20px;
}
.counts { color:var(--muted); font-size:13px; margin:10px 2px 0; }
.error {
display:none; margin-bottom:18px; background:#FDF1EC; border:1px solid rgba(199,95,69,.3);
color:#8C4632; border-radius:14px; padding:12px 16px; font-size:13.5px;
}
#resetRow { display:none; margin-top:14px; padding:0 12px; }
#resetRow a {
font-size:12.5px; color:var(--muted); text-decoration:none; cursor:pointer;
border-bottom:1px dashed transparent; transition:color .15s, border-color .15s;
}
#resetRow a:hover { color:var(--terracotta-deep); border-bottom-color:var(--terracotta-deep); }
footer { grid-column:1 / -1; margin-top:18px; text-align:center; color:var(--muted); font-size:12px; line-height:1.7; }
footer .pitch { font-family:var(--serif); font-style:italic; font-size:13px; color:#5C564D; }
footer a { color:var(--terracotta-deep); text-decoration:none; }
/* ---- conversation detail overlay ---- */
.overlay {
display:none; position:fixed; inset:0; z-index:20; padding:24px;
background:rgba(28,27,25,.34); backdrop-filter:blur(2px);
align-items:flex-start; justify-content:center; overflow-y:auto;
}
.overlay.show { display:flex; }
.sheet {
background:var(--panel); border:1px solid var(--line); border-radius:22px;
max-width:620px; width:100%; margin:auto; padding:26px 26px 30px; position:relative;
box-shadow:0 30px 80px -30px rgba(28,27,25,.5);
animation:rise .35s cubic-bezier(.22,.8,.3,1) forwards;
}
.sheet-close {
position:absolute; top:16px; right:16px; width:30px; height:30px; border:none; cursor:pointer;
border-radius:50%; background:var(--cream); color:var(--muted); font-size:18px; line-height:1;
display:grid; place-items:center;
}
.sheet-close:hover { color:var(--terracotta-deep); }
.sheet-date { font-size:12px; letter-spacing:.04em; color:var(--muted); margin:0 0 4px; }
.sheet h2 {
font-family:var(--serif); font-variation-settings:"SOFT" 50; font-weight:460;
font-size:26px; margin:0 0 16px; padding-right:30px;
}
.sheet .label { margin-top:20px; }
</style>
</head>
<body>
<div class="shell">
<div class="brand-area">
<div class="wordmark">Kith</div>
</div>
<aside class="capture-area">
<section class="ritual">
<div class="ritual-row">
<button id="recordBtn" title="Record a conversation">
<svg viewBox="0 0 24 24"><path d="M12 14a3 3 0 0 0 3-3V6a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3zm5.3-3a5.3 5.3 0 0 1-10.6 0H4.6a7.4 7.4 0 0 0 6.4 7.1V21h2v-2.9a7.4 7.4 0 0 0 6.4-7.1h-2.1z"/></svg>
</button>
<div class="ritual-copy">
<b id="ritualTitle">Capture a conversation</b>
<span id="ritualSub">Tap to record, tap again to finish. Small open models do the rest.</span>
</div>
</div>
<div class="alt-sources">
<a id="uploadLink">Upload a clip</a>
<input type="file" id="fileInput" accept="audio/*">
</div>
<div class="scenarios">
<span class="scenarios-label">Or try a story:</span>
<button class="chip" data-src="/sample-audio">Sara · catching up</button>
<button class="chip" data-src="/clip/sara-wedding">Sara · the wedding</button>
<button class="chip" data-src="/clip/daniel-career">Daniel · career</button>
<button class="chip" data-src="/clip/aunt-rose-call">Aunt Rose · a call</button>
</div>
</section>
</aside>
<nav class="history-area">
<div class="history">
<p class="history-head">Your conversations <span class="n" id="historyCount"></span></p>
<ul id="historyList"></ul>
<p class="history-empty" id="historyEmpty">Nothing yet — capture your first conversation.</p>
<div id="resetRow"><a id="resetLink">Start fresh — forget everything</a></div>
</div>
</nav>
<main class="main-area">
<header>
<h1>For you today</h1>
<p class="date" id="dateLine"></p>
</header>
<div id="steps">
<p class="steps-head">Working on this device…</p>
<div class="step" id="step-prep"><span class="ico"></span><span class="lbl">Preparing audio</span><span class="elapsed"></span></div>
<div class="step" id="step-listen"><span class="ico"></span><span class="lbl">Listening — Cohere Transcribe (2B)</span><span class="elapsed"></span></div>
<div class="step" id="step-remember"><span class="ico"></span><span class="lbl">Remembering — MiniCPM 4.1-8B finds the people &amp; promises</span><span class="elapsed"></span></div>
<div class="step" id="step-today"><span class="ico"></span><span class="lbl">Composing your Today feed</span><span class="elapsed"></span></div>
</div>
<div id="transcriptBox">
<p class="label">What I heard</p>
<div class="transcript" id="transcriptText"></div>
<p class="counts" id="countsLine"></p>
</div>
<div class="error" id="errorBox"></div>
<section id="feed"></section>
</main>
<footer>
<span class="pitch">Small open models, and a memory that's yours.</span><br>
<a href="https://huggingface.co/CohereLabs/cohere-transcribe-03-2026" target="_blank">Cohere Transcribe 2B</a> hears ·
<a href="https://huggingface.co/openbmb/MiniCPM4.1-8B" target="_blank">MiniCPM 4.1-8B</a> remembers · a local SQLite file keeps it<br>
Built small for the <a href="https://huggingface.co/build-small-hackathon" target="_blank">Build Small Hackathon</a>
</footer>
</div>
<div class="overlay" id="overlay">
<div class="sheet">
<button class="sheet-close" id="sheetClose" aria-label="Close">&times;</button>
<p class="sheet-date" id="sheetDate"></p>
<h2 id="sheetTitle"></h2>
<div id="sheetMems"></div>
<p class="label">Full transcript</p>
<div class="transcript" id="sheetTranscript"></div>
</div>
</div>
<script type="module">
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
const $ = (id) => document.getElementById(id);
const recordBtn = $("recordBtn"), steps = $("steps"), errorBox = $("errorBox");
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const TAGS = { date:"Date", want:"Wants", life_event:"Life", preference:"Note",
health:"Health", commitment:"Promise", decision:"Decided" };
// format "2026-06-13" -> "Jun 13" without timezone drift
function shortDate(iso) {
if (!iso) return "";
const [y, m, d] = iso.split("-").map(Number);
return `${MONTHS[m - 1]} ${d}`;
}
$("dateLine").textContent = new Date().toLocaleDateString("en-US",
{ weekday: "long", month: "long", day: "numeric" });
const client = await Client.connect(window.location.origin);
/* ---------- audio: decode anything -> 16k mono wav ---------- */
async function toWav16k(arrayBuffer) {
const probe = new AudioContext();
const decoded = await probe.decodeAudioData(arrayBuffer);
probe.close();
const frames = Math.ceil(decoded.duration * 16000);
const off = new OfflineAudioContext(1, frames, 16000);
const src = off.createBufferSource();
src.buffer = decoded; src.connect(off.destination); src.start();
const rendered = await off.startRendering();
const pcm = rendered.getChannelData(0);
const buf = new ArrayBuffer(44 + pcm.length * 2);
const v = new DataView(buf);
const ws = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
ws(0, "RIFF"); v.setUint32(4, 36 + pcm.length * 2, true); ws(8, "WAVE");
ws(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, 16000, true); v.setUint32(28, 32000, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
ws(36, "data"); v.setUint32(40, pcm.length * 2, true);
for (let i = 0; i < pcm.length; i++) {
const s = Math.max(-1, Math.min(1, pcm[i]));
v.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
let bin = "";
const bytes = new Uint8Array(buf);
for (let i = 0; i < bytes.length; i += 0x8000)
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
return btoa(bin);
}
/* ---------- progress steps ---------- */
const STEP_IDS = ["step-prep", "step-listen", "step-remember", "step-today"];
const stepTimers = {};
function resetSteps() {
for (const id of STEP_IDS) {
const el = $(id);
el.classList.remove("active", "done");
el.querySelector(".elapsed").textContent = "";
clearInterval(stepTimers[id]);
}
$("steps").classList.remove("finished");
}
function setStep(id, state) {
const el = $(id);
const elapsedEl = el.querySelector(".elapsed");
el.classList.remove("active", "done");
clearInterval(stepTimers[id]);
if (state === "active") {
el.classList.add("active");
const start = performance.now();
const tick = () => { elapsedEl.textContent = ((performance.now() - start) / 1000).toFixed(1) + "s"; };
tick();
stepTimers[id] = setInterval(tick, 100);
} else if (state === "done") {
el.classList.add("done");
}
}
function fail(msg) {
errorBox.textContent = msg;
errorBox.style.display = "block";
recordBtn.disabled = false;
}
/* ---------- the Today feed ---------- */
function renderCards(cards) {
const feed = $("feed");
feed.innerHTML = "";
if (!cards.length) {
feed.innerHTML = `<div class="empty">Nothing urgent today. Capture another conversation and Kith will keep listening for birthdays, promises and people you miss.</div>`;
return;
}
cards.forEach((c, i) => {
const el = document.createElement("article");
el.className = `card clickable kind-${c.kind}`;
el.style.animationDelay = `${i * 80}ms`;
el.innerHTML = `
<button class="dismiss" title="Dismiss this card" aria-label="Dismiss">&times;</button>
<span class="person"></span>
<div class="kind"></div>
<h3><span class="title-text"></span><span class="chev">▾</span></h3>
<p class="why"></p>
<div class="detail"><div class="detail-inner"></div></div>`;
el.querySelector(".kind").textContent =
({thoughtful:"Thoughtful",owed:"You owe",reconnect:"Reconnect",remember:"Remember"})[c.kind] ?? c.kind;
el.querySelector(".person").textContent = c.personName;
el.querySelector(".title-text").textContent = c.title;
el.querySelector(".why").textContent = c.why;
el.querySelector(".dismiss").addEventListener("click", (e) => {
e.stopPropagation();
dismissCard(c.id, el);
});
el.addEventListener("click", () => toggleCard(c.id, el));
feed.appendChild(el);
});
}
const detailCache = {};
async function toggleCard(cardId, el) {
if (el.classList.contains("open")) { el.classList.remove("open"); return; }
const panel = el.querySelector(".detail-inner");
if (!detailCache[cardId]) {
panel.innerHTML = `<p class="no-quote">Looking back at what you said…</p>`;
el.classList.add("open");
try {
const res = await client.predict("/card_detail", { card_id: cardId });
detailCache[cardId] = res.data[0];
} catch (err) {
panel.innerHTML = `<p class="no-quote">Couldn't load details.</p>`;
return;
}
} else {
el.classList.add("open");
}
renderDetail(panel, detailCache[cardId], el.querySelector(".person").textContent);
}
// shared memory-list builder (used by card detail + conversation overlay)
function memoryList(memories, { withPerson = false } = {}) {
const ul = document.createElement("ul");
ul.className = "mem-list";
memories.forEach((m) => {
const li = document.createElement("li");
const tag = document.createElement("span");
tag.className = "tag";
tag.textContent = TAGS[m.kind] ?? m.kind;
const txt = document.createElement("span");
txt.textContent = (withPerson && m.person ? m.person + " — " : "") + m.text;
li.append(tag, txt);
if (m.date) {
const when = document.createElement("span");
when.className = "when";
when.textContent = shortDate(m.date);
li.appendChild(when);
}
ul.appendChild(li);
});
return ul;
}
function renderDetail(panel, d, personName) {
if (!d || !d.person) { panel.innerHTML = `<p class="no-quote">No further details.</p>`; return; }
panel.innerHTML = "";
if (d.quote) {
const q = document.createElement("p");
q.className = "quote";
q.textContent = "“" + d.quote + "”";
panel.appendChild(q);
if (d.occurredOn) {
const src = document.createElement("p");
src.className = "quote-src";
src.textContent = "what you heard on " + shortDate(d.occurredOn);
panel.appendChild(src);
}
} else {
const nq = document.createElement("p");
nq.className = "no-quote";
nq.textContent = "Drawn from your conversation" + (d.occurredOn ? " on " + shortDate(d.occurredOn) : "") + ".";
panel.appendChild(nq);
}
if (d.memories?.length) {
const head = document.createElement("p");
head.className = "mem-head";
head.textContent = "Also about " + personName;
panel.appendChild(head);
panel.appendChild(memoryList(d.memories));
}
const meta = document.createElement("p");
meta.className = "meta";
const bits = [];
if (d.person.relationship) bits.push(d.person.relationship);
if (d.person.talkCount) bits.push(d.person.talkCount + (d.person.talkCount === 1 ? " conversation" : " conversations"));
if (d.person.lastContact) bits.push("last spoke " + shortDate(d.person.lastContact));
meta.textContent = bits.join(" · ");
panel.appendChild(meta);
}
async function dismissCard(cardId, el) {
el.classList.add("leaving");
try {
const res = await client.predict("/dismiss", { card_id: cardId });
el.addEventListener("animationend", () => renderCards(res.data[0].cards), { once: true });
} catch (err) {
el.classList.remove("leaving");
fail("Couldn't dismiss that card: " + (err?.message ?? err));
}
}
async function resetAll() {
if (!confirm("Forget everything Kith remembers? This wipes the local memory and can't be undone.")) return;
try {
await client.predict("/reset", {});
renderCards([]);
$("transcriptBox").style.display = "none";
await refreshHistory();
} catch (err) {
fail("Couldn't reset: " + (err?.message ?? err));
}
}
/* ---------- conversation history (rail) ---------- */
async function refreshHistory() {
let convos = [];
try {
const res = await client.predict("/conversations", {});
convos = res.data[0] ?? [];
} catch { /* leave empty */ }
const list = $("historyList");
list.innerHTML = "";
$("historyCount").textContent = convos.length ? convos.length : "";
$("historyEmpty").style.display = convos.length ? "none" : "block";
$("resetRow").style.display = convos.length ? "block" : "none";
convos.forEach((c) => {
const li = document.createElement("li");
li.className = "conv";
li.innerHTML = `<div class="conv-top"><span class="conv-title"></span><span class="conv-date"></span></div><div class="conv-counts"></div>`;
li.querySelector(".conv-title").textContent = c.title;
li.querySelector(".conv-date").textContent = shortDate(c.occurredOn);
const k = c.counts;
const parts = [];
if (k.people) parts.push(`${k.people} ${k.people === 1 ? "person" : "people"}`);
const mem = (k.signals || 0) + (k.commitments || 0);
if (mem) parts.push(`${mem} ${mem === 1 ? "memory" : "memories"}`);
li.querySelector(".conv-counts").textContent = parts.join(" · ") || "no memories";
li.addEventListener("click", () => openConversation(c.id));
list.appendChild(li);
});
}
async function openConversation(id) {
try {
const res = await client.predict("/conversation_detail", { conversation_id: id });
const d = res.data[0];
if (!d || !d.id) return;
$("sheetDate").textContent = "Conversation · " + shortDate(d.occurredOn);
$("sheetTitle").textContent = d.title;
const mems = $("sheetMems");
mems.innerHTML = "";
if (d.memories?.length) {
mems.appendChild(memoryList(d.memories, { withPerson: true }));
} else {
mems.innerHTML = `<p class="no-quote">No memories were extracted from this one.</p>`;
}
$("sheetTranscript").textContent = d.transcript;
$("overlay").classList.add("show");
} catch (err) {
fail("Couldn't open that conversation: " + (err?.message ?? err));
}
}
function closeOverlay() { $("overlay").classList.remove("show"); }
$("sheetClose").addEventListener("click", closeOverlay);
$("overlay").addEventListener("click", (e) => { if (e.target === $("overlay")) closeOverlay(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeOverlay(); });
/* ---------- the loop ---------- */
async function runLoop(arrayBuffer) {
errorBox.style.display = "none";
$("transcriptBox").style.display = "none";
recordBtn.disabled = true;
steps.style.display = "block";
resetSteps();
try {
setStep("step-prep", "active");
const audio_b64 = await toWav16k(arrayBuffer);
setStep("step-prep", "done");
setStep("step-listen", "active");
const t = await client.predict("/transcribe", { audio_b64 });
const transcript = t.data[0];
setStep("step-listen", "done");
$("transcriptText").textContent = transcript;
$("transcriptBox").style.display = "block";
setStep("step-remember", "active");
const ing = await client.predict("/ingest", { transcript });
const { counts } = ing.data[0];
$("countsLine").textContent =
`Remembered ${counts.people} ${counts.people === 1 ? "person" : "people"} · ` +
`${counts.signals} signals · ${counts.commitments} commitments · ${counts.decisions} decisions`;
setStep("step-remember", "done");
setStep("step-today", "active");
const today = await client.predict("/today", {});
renderCards(today.data[0]);
setStep("step-today", "done");
$("steps").classList.add("finished");
await refreshHistory();
} catch (err) {
console.error(err);
for (const id of STEP_IDS) clearInterval(stepTimers[id]);
fail("Something went wrong: " + (err?.message ?? err));
} finally {
recordBtn.disabled = false;
}
}
/* ---------- sources ---------- */
let recorder = null, recChunks = [];
recordBtn.addEventListener("click", async () => {
if (recorder?.state === "recording") { recorder.stop(); return; }
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
recChunks = [];
recorder = new MediaRecorder(stream);
recorder.ondataavailable = (e) => recChunks.push(e.data);
recorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
recordBtn.classList.remove("recording");
$("ritualTitle").textContent = "Capture a conversation";
$("ritualSub").textContent = "Tap to record, tap again to finish. Small open models do the rest.";
const blob = new Blob(recChunks, { type: recorder.mimeType });
await runLoop(await blob.arrayBuffer());
};
recorder.start();
recordBtn.classList.add("recording");
$("ritualTitle").textContent = "Listening…";
$("ritualSub").textContent = "Tap again when the moment has passed.";
} catch (err) {
fail("Microphone unavailable: " + (err?.message ?? err));
}
});
$("resetLink").addEventListener("click", resetAll);
$("uploadLink").addEventListener("click", () => $("fileInput").click());
$("fileInput").addEventListener("change", async (e) => {
const file = e.target.files[0];
if (file) await runLoop(await file.arrayBuffer());
e.target.value = "";
});
document.querySelectorAll(".chip").forEach((chip) => {
chip.addEventListener("click", async () => {
const src = chip.dataset.src;
try {
const resp = await fetch(src);
if (!resp.ok) throw new Error("clip not found (" + resp.status + ")");
await runLoop(await resp.arrayBuffer());
} catch (err) {
fail("Could not load that clip: " + (err?.message ?? err));
}
});
});
/* show whatever's already in the local memory on load */
try {
const today = await client.predict("/today", {});
renderCards(today.data[0] ?? []);
} catch { renderCards([]); }
await refreshHistory();
</script>
</body>
</html>
"""