| """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 & 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">×</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">×</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> |
| """ |
|
|