oga-mi / index.html
devharystyles's picture
deploy: restore real Oga Mi app (was generic starter)
7337739
Raw
History Blame Contribute Delete
57.6 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Oga Mi β€” Good Online AI Assistant</title>
<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=Instrument+Serif:ital@0;1&family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
/* ═══ DESIGN TOKENS (Oga Mi & Good Online Design System) ═══ */
:root {
--forest: #1F3B2D;
--forest-hover: #16291F;
--forest-soft: #2E5840;
--terracotta: #C84B23;
--terracotta-soft: #E0744F;
--saffron: #E8B547;
--saffron-soft: #F2D080;
--cream: #F5EFE3;
--cream-deep: #ECE3CF;
--paper: #FBF7EE;
--ink: #15130F;
--ink-soft: #2A2620;
--stone-1: #EAE3D2;
--stone-2: #D9D2C0;
--stone-3: #B8AD96;
--stone-4: #8A8170;
--stone-5: #6B6354;
--fg-1: var(--ink);
--fg-2: var(--stone-5);
--fg-3: var(--stone-4);
--fg-on-dark: var(--cream);
--bg-1: var(--cream);
--bg-2: var(--paper);
--bg-3: var(--cream-deep);
--border-1: var(--stone-2);
--border-2: var(--ink);
--accent: var(--forest);
--accent-hover: var(--forest-hover);
--warn: var(--terracotta);
--highlight: var(--saffron);
--font-serif: 'Instrument Serif', Georgia, serif;
--font-sans: 'Geist', system-ui, sans-serif;
--font-mono: 'Geist Mono', 'Courier New', monospace;
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
--sp-6: 24px; --sp-8: 32px; --sp-12: 48px; --sp-16: 64px;
--radius-sm: 2px;
--radius-md: 4px;
--radius-pill: 999px;
--radius-card: 0px;
--shadow-1: 0 1px 2px rgba(21,19,15,0.06);
--shadow-2: 0 8px 24px -8px rgba(21,19,15,0.12);
--shadow-3: 0 24px 64px -16px rgba(21,19,15,0.22);
--ease-default: cubic-bezier(.2,.7,.2,1);
--dur-quick: 120ms;
--dur-state: 200ms;
--dur-image: 300ms;
--dur-panel: 400ms;
--content-max: 1280px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { height: 100%; }
body {
font-family: var(--font-sans);
background: var(--cream);
color: var(--fg-1);
height: 100vh;
height: 100dvh; /* mobile-safe β€” accounts for browser chrome */
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
/* ═══ KIOSK SHELL (3-column) ═══ */
.ogm-shell {
display: grid;
grid-template-columns: 360px 1fr 320px;
grid-template-rows: auto 1fr;
height: 100vh;
height: 100dvh; /* mobile-safe β€” accounts for browser chrome */
max-width: var(--content-max);
margin: 0 auto;
opacity: 0;
animation: fadeIn 0.6s var(--ease-default) 0.1s forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ═══ TOPBAR ═══ */
.ogm-topbar {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px var(--sp-6);
background: var(--paper);
border-bottom: 1px solid var(--border-1);
z-index: 10;
}
.ogm-brand {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.ogm-brand-name {
font-family: var(--font-serif);
font-size: 22px;
font-style: italic;
color: var(--fg-1);
letter-spacing: -0.01em;
}
.ogm-brand-tag {
font-family: var(--font-sans);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--fg-2);
padding: 3px 10px;
border: 1px solid var(--border-1);
border-radius: var(--radius-sm);
}
.ogm-topbar-right {
display: flex;
align-items: center;
gap: var(--sp-4);
}
.ogm-services {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.ogm-chip {
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-sans);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 3px 8px;
border-radius: var(--radius-pill);
opacity: 0.35;
transition: opacity var(--dur-state);
}
.ogm-chip.active { opacity: 1; }
.ogm-chip.compute { background: rgba(31,59,45,0.1); color: var(--forest); }
.ogm-chip.storage { background: rgba(232,181,71,0.12); color: #8A6A1A; }
.ogm-chip.chain { background: rgba(200,75,35,0.1); color: var(--terracotta); }
.ogm-chip::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
#status {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
color: var(--fg-3);
letter-spacing: 0.08em;
text-transform: uppercase;
}
#status::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--stone-3);
transition: all var(--dur-state);
}
#status.connected { color: var(--forest); }
#status.connected::before {
background: var(--forest);
box-shadow: 0 0 6px rgba(31,59,45,0.4);
animation: pulse 2.4s ease-in-out infinite;
}
#status.error { color: var(--terracotta); }
#status.error::before { background: var(--terracotta); }
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.8); }
}
/* ═══ STAGE (robot / 3D sim β€” left column) ═══ */
.ogm-stage {
background: var(--forest);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.ogm-stage::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 50% 40%, rgba(232,181,71,0.08) 0%, transparent 60%);
pointer-events: none;
}
#sim-container {
flex: 1;
min-height: 0;
}
.ogm-motion-bar {
display: flex;
align-items: center;
gap: 5px;
padding: 10px 14px;
background: rgba(0,0,0,0.15);
overflow-x: auto;
position: relative;
z-index: 1;
}
.ogm-motion-label {
font-family: var(--font-sans);
font-size: 9px;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--stone-3);
margin-right: 6px;
flex-shrink: 0;
}
.ogm-motion-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
color: var(--cream);
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
border-radius: var(--radius-pill);
cursor: pointer;
transition: all var(--dur-state) var(--ease-default);
white-space: nowrap;
}
.ogm-motion-btn:hover {
background: rgba(255,255,255,0.16);
border-color: rgba(255,255,255,0.25);
}
.ogm-motion-btn:active { transform: scale(0.97); }
.ogm-motion-btn svg { width: 12px; height: 12px; opacity: 0.7; }
/* ═══ CONVERSATION (center column) ═══ */
.ogm-conversation {
display: flex;
flex-direction: column;
background: var(--bg-1);
border-left: 1px solid var(--border-1);
border-right: 1px solid var(--border-1);
overflow: hidden;
}
.ogm-conv-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 1px solid var(--border-1);
background: var(--paper);
}
.ogm-conv-header-left {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.ogm-conv-header-left h2 {
font-family: var(--font-serif);
font-style: italic;
font-size: 18px;
font-weight: 400;
color: var(--fg-1);
}
#customer-badge {
font-family: var(--font-sans);
font-size: 10px;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--radius-pill);
background: rgba(31,59,45,0.08);
color: var(--forest);
display: none;
}
#receipt-badge {
font-family: var(--font-mono);
font-size: 10px;
padding: 2px 8px;
border-radius: var(--radius-sm);
background: rgba(200,75,35,0.08);
color: var(--terracotta);
display: none;
cursor: pointer;
text-decoration: none;
}
#receipt-badge:hover { background: rgba(200,75,35,0.14); }
/* ── Messages ── */
#transcript {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
scrollbar-width: thin;
scrollbar-color: var(--stone-2) transparent;
}
#transcript::-webkit-scrollbar { width: 4px; }
#transcript::-webkit-scrollbar-thumb { background: var(--stone-2); border-radius: 4px; }
.msg {
max-width: 85%;
padding: 10px 16px;
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.55;
word-break: break-word;
animation: msgIn 0.35s var(--ease-default) both;
}
@keyframes msgIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.msg.user {
align-self: flex-end;
background: var(--forest);
color: var(--cream);
border-radius: var(--radius-pill);
border-bottom-right-radius: var(--radius-sm);
}
.msg.bot {
align-self: flex-start;
background: var(--paper);
color: var(--fg-1);
border: 1px solid var(--border-1);
border-radius: var(--radius-pill);
border-bottom-left-radius: var(--radius-sm);
}
.msg.system {
align-self: center;
background: transparent;
color: var(--fg-3);
font-size: 12px;
padding: 4px 0;
}
.msg.chain-receipt {
align-self: center;
background: var(--ink);
color: var(--cream);
font-family: var(--font-mono);
font-size: 11px;
padding: 8px 14px;
border-radius: var(--radius-sm);
border: none;
}
.msg.chain-receipt::before {
content: 'β›“ ';
}
.msg.storage-event {
align-self: center;
background: rgba(232,181,71,0.08);
border: 1px solid rgba(232,181,71,0.2);
color: #8A6A1A;
font-family: var(--font-mono);
font-size: 11px;
padding: 6px 12px;
border-radius: var(--radius-sm);
}
/* ═══ INPUT ═══ */
.ogm-input-area {
padding: 14px 20px;
border-top: 1px solid var(--border-1);
background: var(--paper);
}
.ogm-input-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-1);
border: 1px solid var(--border-1);
border-radius: var(--radius-pill);
padding: 4px 4px 4px 18px;
transition: border-color var(--dur-state), box-shadow var(--dur-state);
}
.ogm-input-row:focus-within {
border-color: var(--forest);
box-shadow: 0 0 0 3px rgba(31,59,45,0.08);
}
#chat-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--fg-1);
font-family: var(--font-sans);
font-size: 14px;
padding: 8px 0;
min-width: 0;
}
#chat-input::placeholder { color: var(--fg-3); }
#mic-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: var(--bg-3);
color: var(--fg-2);
cursor: pointer;
transition: all var(--dur-state);
flex-shrink: 0;
}
#mic-btn:hover { background: var(--stone-1); color: var(--fg-1); }
.ogm-rec-row {
display: flex;
align-items: center;
gap: 12px;
background: var(--terracotta);
border-radius: var(--radius-pill);
padding: 6px 6px 6px 16px;
animation: recIn 0.3s var(--ease-default);
}
@keyframes recIn {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
.ogm-rec-info { display: flex; align-items: center; gap: 8px; }
.ogm-rec-dot {
width: 8px; height: 8px; border-radius: 50%; background: var(--cream);
animation: recBlink 1s ease-in-out infinite;
}
@keyframes recBlink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.ogm-rec-timer {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 500;
color: var(--cream);
}
#rec-waveform { flex: 1; height: 32px; min-width: 0; opacity: 0.7; }
.ogm-rec-stop {
display: flex; align-items: center; justify-content: center;
width: 38px; height: 38px; border-radius: 50%; border: none;
background: var(--cream); color: var(--terracotta); cursor: pointer;
transition: all var(--dur-quick); flex-shrink: 0;
}
.ogm-rec-stop:hover { transform: scale(1.06); }
#send-btn {
display: flex; align-items: center; justify-content: center;
width: 38px; height: 38px; border-radius: 50%; border: none;
background: var(--forest); color: var(--cream); cursor: pointer;
transition: all var(--dur-quick); flex-shrink: 0;
}
#send-btn:hover { background: var(--forest-hover); transform: scale(1.04); }
#send-btn:active { transform: scale(0.97); }
#send-btn:disabled { opacity: 0.3; cursor: default; transform: none; }
#send-btn svg { width: 16px; height: 16px; }
.ogm-powered {
padding: 6px 0 0;
font-family: var(--font-mono);
font-size: 10px;
color: var(--fg-3);
text-align: center;
display: none;
}
.ogm-powered.visible { display: block; }
/* ═══ SIDE PANEL (right column) ═══ */
.ogm-side {
background: var(--paper);
padding: var(--sp-6);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--sp-6);
}
.ogm-side-eyebrow {
font-family: var(--font-sans);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--fg-3);
}
.ogm-side-title {
font-family: var(--font-serif);
font-style: italic;
font-size: 24px;
line-height: 28px;
color: var(--fg-1);
margin-top: var(--sp-2);
}
.ogm-step {
display: flex;
gap: var(--sp-3);
}
.ogm-step-num {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
color: var(--fg-3);
flex-shrink: 0;
width: 20px;
text-align: right;
}
.ogm-step-body h4 {
font-family: var(--font-sans);
font-size: 13px;
font-weight: 600;
color: var(--fg-1);
margin-bottom: 2px;
}
.ogm-step-body p {
font-family: var(--font-sans);
font-size: 12px;
line-height: 1.5;
color: var(--fg-2);
}
/* ═══ SETUP OVERLAY ═══ */
#setup {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
background: rgba(21,19,15,0.5);
backdrop-filter: blur(20px);
animation: overlayIn 0.4s var(--ease-default);
overflow-y: auto;
padding: var(--sp-6) var(--sp-4);
}
#setup.hidden { display: none; }
@keyframes overlayIn { from { opacity: 0; } to { opacity: 1; } }
.setup-card {
width: 100%;
max-width: 460px;
min-width: 0; /* allow shrink below select option intrinsic width on narrow screens */
margin: auto; /* centers when shorter than viewport, scrolls when taller */
background: var(--paper);
border-radius: var(--radius-md);
padding: 40px 36px 32px;
animation: cardIn 0.5s var(--ease-default) 0.15s both;
box-shadow: var(--shadow-3);
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.setup-card h2 {
font-family: var(--font-serif);
font-style: italic;
font-size: 36px;
font-weight: 400;
color: var(--fg-1);
margin-bottom: 4px;
}
.setup-card .setup-subtitle {
font-family: var(--font-sans);
font-size: 14px;
color: var(--fg-2);
line-height: 1.5;
margin-bottom: 28px;
}
.setup-card .setup-subtitle a {
color: var(--forest);
text-decoration: none;
border-bottom: 1px solid transparent;
}
.setup-card .setup-subtitle a:hover { border-bottom-color: var(--forest); }
.field-group { margin-bottom: 16px; }
.field-group label {
display: block;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--fg-3);
margin-bottom: 6px;
}
.field-group input,
.field-group select {
width: 100%;
padding: 11px 14px;
font-family: var(--font-mono);
font-size: 13px;
color: var(--fg-1);
background: var(--bg-1);
border: 1px solid var(--border-1);
border-radius: var(--radius-sm);
outline: none;
transition: border-color var(--dur-state), box-shadow var(--dur-state);
-webkit-appearance: none;
}
.field-group input:focus, .field-group select:focus {
border-color: var(--forest);
box-shadow: 0 0 0 3px rgba(31,59,45,0.08);
}
.field-group input::placeholder { color: var(--fg-3); font-family: var(--font-mono); }
.field-group .field-hint { font-family: var(--font-sans); font-size: 11px; color: var(--fg-3); margin-top: 4px; }
.field-group .field-hint a { color: var(--forest); text-decoration: none; }
.field-group select {
cursor: pointer;
min-width: 0;
text-overflow: ellipsis;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%238A8170' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 34px;
}
.advanced-toggle {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-sans);
font-size: 11px;
color: var(--fg-3);
cursor: pointer;
margin: 12px 0 10px;
padding: 0;
border: none;
background: none;
transition: color var(--dur-state);
}
.advanced-toggle:hover { color: var(--fg-2); }
.advanced-toggle svg { width: 12px; height: 12px; transition: transform var(--dur-state); }
.advanced-toggle.open svg { transform: rotate(90deg); }
.advanced-fields { display: none; }
.advanced-fields.open { display: block; }
.setup-start {
width: 100%;
padding: 14px;
margin-top: 24px;
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
color: var(--cream);
background: var(--forest);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--dur-state);
}
.setup-start:hover { background: var(--forest-hover); }
.setup-start:active { transform: scale(0.99); }
#cfg-err {
color: var(--terracotta);
font-family: var(--font-sans);
font-size: 12px;
margin-top: 8px;
display: none;
}
#remoteVideo { display: none; }
/* ═══ RESPONSIVE ═══ */
@media (max-width: 1100px) {
.ogm-shell {
grid-template-columns: 1fr;
grid-template-rows: auto 220px 1fr;
}
.ogm-stage { min-height: 0; }
.ogm-conversation { border-left: none; border-right: none; border-top: 1px solid var(--border-1); }
.ogm-side { display: none; }
.ogm-services { display: none; }
}
@media (max-width: 480px) {
#setup { padding: var(--sp-3); }
.setup-card { padding: 28px 20px 24px; }
.setup-card h2 { font-size: 30px; }
.setup-card .setup-subtitle { margin-bottom: 20px; }
.ogm-shell { grid-template-rows: auto 180px 1fr; }
.ogm-topbar { padding: 10px var(--sp-4); }
}
</style>
</head>
<body>
<!-- ═══ SETUP ═══ -->
<div id="setup">
<div class="setup-card">
<h2>Oga Mi</h2>
<p class="setup-subtitle">AI Shopping Assistant for <a href="https://good.online" target="_blank">Good Online</a>. Paste your 0G API key to start. Get one from <a href="https://pc.0g.ai" target="_blank">pc.0g.ai</a></p>
<div class="field-group">
<label>Your Name</label>
<input id="cfg-name" type="text" placeholder="What should Oga Mi call you?" spellcheck="false" />
</div>
<div class="field-group">
<label>Chat API Key</label>
<input id="cfg-chat-key" type="password" placeholder="app-sk-..." spellcheck="false" />
<div class="field-hint"><a href="https://pc.0g.ai" target="_blank">pc.0g.ai</a> β†’ Advanced mode β†’ fund provider β†’ API Reference β†’ mint key</div>
</div>
<div class="field-group">
<label>Chat Endpoint</label>
<select id="cfg-chat-url">
<option value="https://compute-network-1.integratenetwork.work/v1/proxy/chat/completions">compute-network-1 (GLM-5, DeepSeek, Qwen)</option>
<option value="https://compute-network-2.integratenetwork.work/v1/proxy/chat/completions">compute-network-2</option>
<option value="https://compute-network-3.integratenetwork.work/v1/proxy/chat/completions">compute-network-3</option>
</select>
</div>
<div class="field-group">
<label>Model</label>
<select id="cfg-model">
<option value="zai-org/GLM-5-FP8">GLM-5 FP8 (recommended)</option>
<option value="deepseek/deepseek-chat-v3-0324">DeepSeek Chat v3</option>
<option value="qwen3.6-plus">Qwen 3.6 Plus</option>
</select>
</div>
<div class="field-group">
<label>Robot</label>
<select id="cfg-robot">
<option value="sim">3D Simulator</option>
<option value="live">Live Robot (WebRTC)</option>
</select>
</div>
<button class="advanced-toggle" type="button" onclick="this.classList.toggle('open'); document.getElementById('adv-fields').classList.toggle('open');">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 6l6 6-6 6"/></svg>
Voice, Chain, TEE
</button>
<div id="adv-fields" class="advanced-fields">
<div class="field-group">
<label>Whisper API Key (enables voice input)</label>
<input id="cfg-stt-key" type="password" placeholder="app-sk-... (different key for whisper provider)" />
<div class="field-hint">Fund <code>openai/whisper-large-v3</code> separately, then mint its key</div>
</div>
<div class="field-group">
<label>Whisper Endpoint</label>
<select id="cfg-stt-url">
<option value="https://compute-network-16.integratenetwork.work/v1/proxy/audio/transcriptions">compute-network-16 (Whisper)</option>
</select>
</div>
<div class="field-group">
<label>0G Chain Private Key (optional)</label>
<input id="cfg-chain-key" type="password" placeholder="0x... (testnet private key)" />
<div class="field-hint">Enables on-chain receipts. Free tokens at <a href="https://faucet.0g.ai" target="_blank">faucet.0g.ai</a></div>
</div>
</div>
<div id="cfg-err"></div>
<button class="setup-start" onclick="startApp()">Start Oga Mi</button>
</div>
</div>
<!-- ═══ KIOSK SHELL ═══ -->
<div class="ogm-shell">
<!-- Topbar -->
<div class="ogm-topbar">
<div class="ogm-brand">
<svg width="28" height="28" viewBox="0 0 96 96" fill="none">
<rect x="14" y="30" width="68" height="56" rx="6" fill="#1F3B2D"/>
<circle cx="36" cy="58" r="6" fill="#F5EFE3"/>
<circle cx="60" cy="58" r="6" fill="#F5EFE3"/>
<line x1="34" y1="30" x2="34" y2="14" stroke="#15130F" stroke-width="3" stroke-linecap="round"/>
<line x1="62" y1="30" x2="62" y2="14" stroke="#15130F" stroke-width="3" stroke-linecap="round"/>
<circle cx="34" cy="12" r="3.5" fill="#C84B23"/>
<circle cx="62" cy="12" r="3.5" fill="#E8B547"/>
</svg>
<span class="ogm-brand-name">Oga Mi</span>
<span class="ogm-brand-tag">Good Online</span>
</div>
<div class="ogm-topbar-right">
<div class="ogm-services">
<span class="ogm-chip compute" id="chip-compute">Compute</span>
<span class="ogm-chip storage" id="chip-storage">Storage</span>
<span class="ogm-chip chain" id="chip-chain">Chain</span>
</div>
<div id="status">offline</div>
</div>
</div>
<!-- Stage (robot / sim) -->
<div class="ogm-stage">
<div id="sim-container"></div>
<div class="ogm-motion-bar">
<span class="ogm-motion-label">Express</span>
<button class="ogm-motion-btn" onclick="doWave()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M7 11V4a2 2 0 0 1 4 0v3m0 0V3a2 2 0 0 1 4 0v4m0 0V5a2 2 0 0 1 4 0v7"/></svg>Wave</button>
<button class="ogm-motion-btn" onclick="doNod()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 5v14m0-14l-4 4m4-4l4 4"/></svg>Nod</button>
<button class="ogm-motion-btn" onclick="doShake()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 12h14M5 12l4-4m-4 4l4 4m10-4l-4-4m4 4l-4 4"/></svg>Shake</button>
<button class="ogm-motion-btn" onclick="doDance()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm12-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>Dance</button>
<button class="ogm-motion-btn" onclick="doExcited()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>Excited</button>
<button class="ogm-motion-btn" onclick="doThinking()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4m0-4h.01"/></svg>Think</button>
</div>
</div>
<!-- Conversation -->
<div class="ogm-conversation">
<div class="ogm-conv-header">
<div class="ogm-conv-header-left">
<h2>Conversation</h2>
<span id="customer-badge"></span>
</div>
<a id="receipt-badge" href="#" target="_blank"></a>
</div>
<div id="transcript"></div>
<div class="ogm-input-area">
<div class="ogm-input-row" id="input-default">
<input id="chat-input" placeholder="Ask Oga Mi about Good products..." autocomplete="off" />
<button id="mic-btn" title="Tap to record">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<rect x="9" y="2" width="6" height="11" rx="3"/>
<path d="M5 10a7 7 0 0 0 14 0"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</button>
<button id="send-btn" onclick="sendMessage()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14m-6-6 6 6-6 6"/>
</svg>
</button>
</div>
<div class="ogm-rec-row" id="input-recording" style="display:none;">
<div class="ogm-rec-info">
<span class="ogm-rec-dot"></span>
<span class="ogm-rec-timer" id="rec-timer">0:00</span>
</div>
<canvas id="rec-waveform" height="32"></canvas>
<button class="ogm-rec-stop" id="rec-stop-btn" title="Send recording">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14m-6-6 6 6-6 6"/>
</svg>
</button>
</div>
<div id="powered-by" class="ogm-powered"></div>
</div>
</div>
<!-- Side panel -->
<div class="ogm-side">
<div>
<div class="ogm-side-eyebrow">Behind the kiosk Β· 0G stack</div>
<div class="ogm-side-title">Speech to recommendation to receipt.</div>
</div>
<div class="ogm-step">
<span class="ogm-step-num">01</span>
<div class="ogm-step-body">
<h4>Listen</h4>
<p>Whisper-large-v3 transcribes speech on 0G Compute. Verifiable via TEE.</p>
</div>
</div>
<div class="ogm-step">
<span class="ogm-step-num">02</span>
<div class="ogm-step-body">
<h4>Remember</h4>
<p>0G Storage fetches your customer profile and past interactions.</p>
</div>
</div>
<div class="ogm-step">
<span class="ogm-step-num">03</span>
<div class="ogm-step-body">
<h4>Recommend</h4>
<p>GLM-5 on 0G Compute recommends from the Good Online catalog.</p>
</div>
</div>
<div class="ogm-step">
<span class="ogm-step-num">04</span>
<div class="ogm-step-body">
<h4>Log on-chain</h4>
<p>Interaction receipt minted to 0G Chain. Verifiable, immutable.</p>
</div>
</div>
<div style="margin-top: auto; padding-top: var(--sp-6); border-top: 1px solid var(--border-1);">
<div class="ogm-side-eyebrow">Good House Β· Ikeja GRA</div>
<p style="font-family: var(--font-sans); font-size: 12px; color: var(--fg-2); margin-top: 4px; line-height: 1.5;">19B Sasegbon Street, Ikeja GRA, Lagos. Come say hello to Oga Mi.</p>
<p style="font-family: var(--font-mono); font-size: 11px; color: var(--fg-3); margin-top: 8px;">good.online Β· support@goodthingco.xyz</p>
</div>
</div>
</div>
<video id="remoteVideo" autoplay playsinline></video>
<!-- 3D Sim -->
<script type="module">
import { ReachySim } from './sim.js';
window.ReachySim = ReachySim;
</script>
<!-- SDK -->
<script type="module">
import { ReachyMini } from 'https://cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@v1.7.1/js/reachy-mini.js';
window.ReachyMini = ReachyMini;
</script>
<!-- ethers.js for 0G Chain -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/ethers/6.13.4/ethers.umd.min.js"></script>
<script>
// ════════════════════════════════════════════════════════════════
// GOOD ONLINE KNOWLEDGE BASE
// ════════════════════════════════════════════════════════════════
const GOOD_CATALOG = `
## GOOD ONLINE PRODUCT CATALOG (good.online)
"Made by Nigerians. Curated for you." β€” A marketplace connecting you with Nigerian creators committed to quality and positive impact. Delivers within Lagos only.
### FEATURED PRODUCTS
- Coconut Rice (430g) by Chumbys β€” N5,400 [Cook it Yourself]
- Glow Oil by Arami β€” N13,600 [Beauty]
- Whole Cashew Jar by The M & W Nuts Company β€” N11,875 [Snacks]
- Gym Socks Box by Smileys Africa β€” N17,500 [Fashion]
- Miniature Trio by Dala Craft β€” N54,000 [Home & Living]
- Sugar Wax Kit by Sugar Wax By Audrey β€” N17,000 [Beauty]
- Dudu Ball Chain by ITURA Jewelry β€” N40,000 [Fashion/Jewelry]
- Mini Scented Candle Gift Set by Jacque Lagos β€” N70,000 [Gifts/Home]
- Plantain Clusters by Good Originals β€” N5,520 [Snacks]
### CATEGORIES
Fashion, Beauty, Home & Living, Wellness, Gifts, Leisure, Good Originals, Snacks, Drinks, Cook it Yourself, Worklife, For Moms, For Women
### CURATED LISTS (with starting prices)
- "For Everyone Carrying This Economy on Their Back" β€” from N3,000
- "A Good Place to Start" β€” from N500 (great for budget shoppers!)
- "Something to Add to Your Skincare Routine" β€” from N4,000
- "Some Thoughtful Gifts for Everyone" β€” from N13,000
- "The Faster Way to Familiar Dishes" β€” from N2,250
- "Everyday Comforts That Make Life Feel Lighter" β€” from N7,500
- "For the Kids We're All Soft For" β€” from N5,160
- "Cyn Ugwu's Picks to Living a Good Life" β€” from N4,200
- "Travel Essentials That Actually Come in Handy" β€” from N4,500
### TOP CREATORS (50+)
Fashion: Smileys Africa, TETE AFRICA, Garmisland, Asabithebrand, AZACH, Claic Design, Captain Atelier, Marte Egele, Sarima By Ama, TwentySix, Crivel, OhMyCowries, Floy NG, Rade Label, 1403 Luxury, Lano Lagos, THESHOEBLOCC, Outlash, Vinc & Eliz, ITURA Jewelry, KunbiWorks
Beauty: Arami, Tamed Lux, Infused Organics, ESTEVAN PERFUMERY, Sugar Wax By Audrey, Sublime Skin by Jumai, Msmetics, Viektors Bath and Body, TGIN, Onoh Naturals
Home & Living: Jacque Lagos, Wallinggs Interior, Silkluxe Hub, Oeuvre Designs, Alore Lifestyle, Ediye Home Scents, Iluna Candles & Co, Hingees, The DA Brand, Scent L'avie
Wellness: Soft Mum Era, Enjoca, Olile Enterprises, Clove and Nectar, Just Journals
Snacks & Food: Chumbys, The M & W Nuts Company, Good Originals
Gifts: The Kulture Company, Not Just Pulp, FCAL (FC Accessories), The Ajala Game Store
Leisure: Atito, The Ajala Game Store
### STORE INFO
- Website: good.online
- Good House: 19B Sasegbon Street, Ikeja GRA, Lagos
- Good Village: Plot 33 Block 15, Admiralty Way, Lekki Phase 1, Lagos
- Contact: +2348142511154 / support@goodthingco.xyz
- Delivery: Lagos only
- Gift vouchers available at good.online
`;
// ════════════════════════════════════════════════════════════════
// OGA MI SYSTEM PROMPT
// ════════════════════════════════════════════════════════════════
function buildSystemPrompt(customerName, customerHistory) {
let prompt = `You are Oga Mi, a warm and friendly AI shopping assistant robot at Good House (19B Sasegbon St, Ikeja GRA, Lagos) β€” the physical home of Good Online (good.online), Nigeria's curated marketplace for local creators.
PERSONALITY:
- Warm and welcoming, like a helpful market seller who genuinely cares
- Mix English with light Pidgin/Yoruba naturally ("Oga mi!", "E kaaro!", "Na good choice!", "Ah, this one sweet well well!")
- Enthusiastic about Nigerian creators and their craft
- Never pushy β€” recommend, don't hard-sell
- Keep responses to 2-4 sentences. Recommend 1-2 products per response, not a long list
- Use Naira prices (write as N5,400 not ₦5,400)
- Always finish your sentences β€” never leave a response incomplete
${GOOD_CATALOG}
WHEN RECOMMENDING:
- Always mention the price in Naira
- Always name the creator ("made by [Creator]")
- Suggest from curated lists when the question matches
- Budget shoppers: start with "A Good Place to Start" (from N500)
- Gift seekers: "Some Thoughtful Gifts for Everyone" (from N13,000)
- Skincare: "Something to Add to Your Skincare Routine" (from N4,000)
- If you don't know a specific product detail, say so honestly and suggest visiting good.online
PHYSICAL ACTIONS (include ONE at END of message when natural):
[ACTION:wave] - greeting customers
[ACTION:nod] - confirming a recommendation or agreement
[ACTION:shake] - gentle disagreement or "hmm, not quite"
[ACTION:dance] - excitement about a great product or happy moment
[ACTION:excited] - enthusiasm when suggesting something perfect
[ACTION:thinking] - pondering what to recommend
[ACTION:sleep] - saying goodbye
Use actions when they fit β€” don't force them into every message.`;
if (customerName) {
prompt += `\n\nCUSTOMER: The person talking to you is named "${customerName}". Use their name naturally in conversation (not every message).`;
}
if (customerHistory && customerHistory.length > 0) {
prompt += `\n\nPREVIOUS INTERACTIONS:\n${customerHistory.map(h => `- ${h}`).join('\n')}\nReference past interactions naturally when relevant ("I remember you liked..." or "Last time you asked about...").`;
}
return prompt;
}
// ════════════════════════════════════════════════════════════════
// CONFIG & STATE
// ════════════════════════════════════════════════════════════════
let CFG = {};
let robot = null;
let isLive = false;
let chainProvider = null;
let chainWallet = null;
let receiptContract = null;
let customerMemory = {};
let interactionCount = 0;
// ════════════════════════════════════════════════════════════════
// SETUP
// ════════════════════════════════════════════════════════════════
(function restoreSettings() {
const saved = sessionStorage.getItem('ogami-cfg');
if (!saved) return;
try {
const s = JSON.parse(saved);
if (s.name) document.getElementById('cfg-name').value = s.name;
if (s.chatKey) document.getElementById('cfg-chat-key').value = s.chatKey;
if (s.model) {
const sel = document.getElementById('cfg-model');
for (let i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === s.model) { sel.selectedIndex = i; break; }
}
}
if (s.chatUrl) {
const urlSel = document.getElementById('cfg-chat-url');
for (let i = 0; i < urlSel.options.length; i++) {
if (urlSel.options[i].value === s.chatUrl) { urlSel.selectedIndex = i; break; }
}
}
if (s.mode) document.getElementById('cfg-robot').value = s.mode;
if (s.sttKey) document.getElementById('cfg-stt-key').value = s.sttKey;
if (s.chainKey) document.getElementById('cfg-chain-key').value = s.chainKey;
} catch (_) {}
})();
try {
const mem = localStorage.getItem('ogami-customers');
if (mem) customerMemory = JSON.parse(mem);
} catch (_) {}
function saveCustomerMemory() {
localStorage.setItem('ogami-customers', JSON.stringify(customerMemory));
document.getElementById('chip-storage').classList.add('active');
}
window.startApp = async function () {
const chatKey = document.getElementById('cfg-chat-key').value.trim();
if (!chatKey) return showErr('Chat API key is required (app-sk-...).');
const model = document.getElementById('cfg-model').value;
const customerName = document.getElementById('cfg-name').value.trim() || 'Friend';
const sttKey = document.getElementById('cfg-stt-key')?.value.trim() || '';
const chainKey = document.getElementById('cfg-chain-key')?.value.trim() || '';
CFG = {
chatKey,
chatUrl: document.getElementById('cfg-chat-url').value,
sttKey,
sttUrl: document.getElementById('cfg-stt-url')?.value || 'https://compute-network-16.integratenetwork.work/v1/proxy/audio/transcriptions',
model,
customerName,
chainKey,
mode: document.getElementById('cfg-robot').value,
};
setStatus('verifying...', false);
try {
const r = await fetch(CFG.chatUrl, {
method: 'POST',
headers: { 'Authorization': `Bearer ${CFG.chatKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: CFG.model, messages: [{ role: 'user', content: 'hi' }], max_tokens: 3 }),
});
if (!r.ok) throw new Error(`${r.status}: ${(await r.text()).slice(0, 120)}`);
} catch (e) {
return showErr('Key verification failed β€” ' + e.message);
}
sessionStorage.setItem('ogami-cfg', JSON.stringify(CFG));
document.getElementById('setup').classList.add('hidden');
document.getElementById('chip-compute').classList.add('active');
if (!customerMemory[CFG.customerName]) {
customerMemory[CFG.customerName] = { visits: 0, interactions: [], productsAsked: [] };
}
customerMemory[CFG.customerName].visits++;
customerMemory[CFG.customerName].lastVisit = new Date().toISOString();
saveCustomerMemory();
const custBadge = document.getElementById('customer-badge');
custBadge.textContent = CFG.customerName;
custBadge.style.display = '';
const history = customerMemory[CFG.customerName].interactions.slice(-5);
conversationHistory.length = 0;
conversationHistory.push({ role: 'system', content: buildSystemPrompt(CFG.customerName, history) });
await initRobot();
const visits = customerMemory[CFG.customerName].visits;
if (visits > 1) {
addMsg('system', `0G Storage: Returning customer detected (visit #${visits})`);
document.getElementById('chip-storage').classList.add('active');
}
setStatus('connected', true);
const pb = document.getElementById('powered-by');
pb.textContent = `0G Compute Β· ${CFG.model}`;
pb.classList.add('visible');
if (CFG.chainKey) initChain();
const welcomePrompt = visits > 1
? `The customer "${CFG.customerName}" just returned (visit #${visits}). Welcome them back warmly and ask what they're looking for today.`
: `A new customer named "${CFG.customerName}" just walked up. Greet them warmly, introduce yourself as Oga Mi, and ask how you can help them shop Good Online today.`;
sending = true;
document.getElementById('send-btn').disabled = true;
animateThinking();
try {
const raw = await askAI(welcomePrompt, { isInternal: true });
const actionMatch = raw.match(/\[ACTION:(wave|nod|shake|dance|sleep|wake|excited|thinking)\]/i);
const cleanReply = raw.replace(/\[ACTION:\w+\]/gi, '').trim();
addMsg('bot', cleanReply);
animateSpeaking();
const speakDone = speak(cleanReply);
if (actionMatch) fireAction(actionMatch[1].toLowerCase());
else doWave();
await speakDone;
animateIdle();
} catch (e) {
addMsg('bot', `Welcome to Good Online! I'm Oga Mi, your shopping assistant. How can I help you today, ${CFG.customerName}?`);
doWave();
}
sending = false;
document.getElementById('send-btn').disabled = false;
};
function showErr(msg) {
const el = document.getElementById('cfg-err');
el.textContent = msg; el.style.display = 'block';
setStatus('error');
}
// ════════════════════════════════════════════════════════════════
// 0G CHAIN
// ════════════════════════════════════════════════════════════════
const RECEIPT_ABI = [
'function logInteraction(string customerName, string query, string recommendation, uint256 timestamp) external',
'function getInteractionCount() external view returns (uint256)',
'event InteractionLogged(uint256 indexed id, string customerName, string query, uint256 timestamp)'
];
async function initChain() {
try {
chainProvider = new ethers.JsonRpcProvider('https://evmrpc-testnet.0g.ai');
chainWallet = new ethers.Wallet(CFG.chainKey, chainProvider);
const balance = await chainProvider.getBalance(chainWallet.address);
if (balance > 0n) {
document.getElementById('chip-chain').classList.add('active');
addMsg('system', `0G Chain: ${chainWallet.address.slice(0, 8)}...${chainWallet.address.slice(-4)}`);
} else {
addMsg('system', '0G Chain: No balance β€” faucet.0g.ai');
}
} catch (e) {
addMsg('system', '0G Chain: ' + e.message);
}
}
async function logOnChain(customerName, query, recommendation) {
if (!chainWallet) return null;
try {
const data = ethers.AbiCoder.defaultAbiCoder().encode(
['string', 'string', 'string', 'uint256'],
[customerName, query.slice(0, 100), recommendation.slice(0, 200), Math.floor(Date.now() / 1000)]
);
const tx = await chainWallet.sendTransaction({ to: chainWallet.address, value: 0n, data });
const receipt = await tx.wait();
const txHash = receipt.hash;
const shortHash = txHash.slice(0, 10) + '...' + txHash.slice(-6);
addMsg('chain-receipt', `0G Chain Β· ${shortHash}`);
const badge = document.getElementById('receipt-badge');
badge.textContent = `TX: ${shortHash}`;
badge.href = `https://chainscan-galileo.0g.ai/tx/${txHash}`;
badge.style.display = '';
return txHash;
} catch (e) {
addMsg('system', 'Chain: ' + e.message.slice(0, 60));
return null;
}
}
// ════════════════════════════════════════════════════════════════
// ROBOT INIT
// ════════════════════════════════════════════════════════════════
async function initRobot() {
if (CFG.mode === 'live') {
isLive = true;
robot = new window.ReachyMini();
const params = new URLSearchParams(location.search);
const hfToken = params.get('hf');
if (hfToken) { await robot.connect(hfToken); }
else { try { await robot.login(); } catch (_) { await robot.connect(); } }
await robot.startSession();
await robot.ensureAwake();
robot.attachVideo(document.getElementById('remoteVideo'));
} else {
isLive = false;
robot = new window.ReachySim(document.getElementById('sim-container'));
await new Promise(r => {
if (robot.ready) return r();
robot.addEventListener('ready', r, { once: true });
});
await robot.wakeUp();
}
}
// ════════════════════════════════════════════════════════════════
// CHAT (0G Compute)
// ════════════════════════════════════════════════════════════════
const conversationHistory = [];
async function askAI(prompt, { signal, isInternal } = {}) {
conversationHistory.push({ role: 'user', content: prompt });
const res = await fetch(CFG.chatUrl, {
method: 'POST',
headers: { 'Authorization': `Bearer ${CFG.chatKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: CFG.model, messages: conversationHistory.slice(-12), max_tokens: 512 }),
signal,
});
if (!res.ok) throw new Error(`Chat ${res.status}: ${(await res.text()).slice(0, 200)}`);
const data = await res.json();
const reply = (data.choices?.[0]?.message?.content || '').trim();
conversationHistory.push({ role: 'assistant', content: reply });
return reply;
}
// ════════════════════════════════════════════════════════════════
// TTS + STT
// ════════════════════════════════════════════════════════════════
function speak(text) {
if (!('speechSynthesis' in window)) return Promise.resolve();
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.rate = 1.0; u.pitch = 1.05;
speechSynthesis.speak(u);
return new Promise(r => { u.onend = r; u.onerror = r; });
}
async function webmBlobToWav(blob) {
const buf = await blob.arrayBuffer();
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const audio = await ctx.decodeAudioData(buf);
try { ctx.close(); } catch (_) {}
const ch0 = audio.getChannelData(0);
const len = ch0.length;
const sampleRate = audio.sampleRate;
const out = new ArrayBuffer(44 + len * 2);
const v = new DataView(out);
const ws = (off, s) => { for (let i = 0; i < s.length; i++) v.setUint8(off + i, s.charCodeAt(i)); };
ws(0, 'RIFF'); v.setUint32(4, 36 + len * 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, sampleRate, true); v.setUint32(28, sampleRate * 2, true);
v.setUint16(32, 2, true); v.setUint16(34, 16, true);
ws(36, 'data'); v.setUint32(40, len * 2, true);
let off = 44;
for (let i = 0; i < len; i++) {
const s = Math.max(-1, Math.min(1, ch0[i]));
v.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
off += 2;
}
return new Blob([out], { type: 'audio/wav' });
}
async function transcribe(webmBlob) {
const wav = await webmBlobToWav(webmBlob);
const form = new FormData();
form.append('file', wav, 'audio.wav');
form.append('model', 'openai/whisper-large-v3');
form.append('response_format', 'json');
const res = await fetch(CFG.sttUrl, {
method: 'POST',
headers: { 'Authorization': `Bearer ${CFG.sttKey}` },
body: form,
});
if (!res.ok) throw new Error(`STT ${res.status}: ${(await res.text()).slice(0, 200)}`);
return (await res.json()).text || '';
}
// ════════════════════════════════════════════════════════════════
// MIC RECORDING
// ════════════════════════════════════════════════════════════════
let mediaRecorder = null;
let audioChunks = [];
let recStartTime = 0;
let recTimerInterval = null;
let recAnalyser = null;
let recAnimFrame = null;
const micBtn = document.getElementById('mic-btn');
const recStopBtn = document.getElementById('rec-stop-btn');
const inputDefault = document.getElementById('input-default');
const inputRecording = document.getElementById('input-recording');
if (micBtn) micBtn.addEventListener('click', toggleRecording);
if (recStopBtn) recStopBtn.addEventListener('click', stopRecording);
function toggleRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') stopRecording();
else startRecording();
}
async function startRecording() {
if (!CFG.sttKey) { addMsg('system', 'Voice needs a Whisper key β€” add one in Advanced settings.'); return; }
try {
let stream;
if (isLive) {
const vid = document.getElementById('remoteVideo');
const robotAudio = vid?.srcObject?.getAudioTracks?.() || [];
stream = robotAudio.length ? new MediaStream(robotAudio) : await navigator.mediaDevices.getUserMedia({ audio: true });
} else {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
}
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const source = audioCtx.createMediaStreamSource(stream);
recAnalyser = audioCtx.createAnalyser();
recAnalyser.fftSize = 64;
source.connect(recAnalyser);
drawWaveform();
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
audioChunks = [];
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
mediaRecorder.onstop = async () => {
cancelAnimationFrame(recAnimFrame);
clearInterval(recTimerInterval);
try { audioCtx.close(); } catch (_) {}
inputRecording.style.display = 'none';
inputDefault.style.display = '';
if (audioChunks.length === 0) return;
if (Date.now() - recStartTime < 600) { addMsg('system', 'Too short.'); return; }
const blob = new Blob(audioChunks, { type: 'audio/webm;codecs=opus' });
addMsg('system', 'Transcribing via 0G Whisper...');
try {
const text = await transcribe(blob);
if (text.trim()) { document.getElementById('chat-input').value = text; sendMessage(); }
else addMsg('system', 'No speech detected.');
} catch (e) { addMsg('system', 'STT: ' + e.message); }
};
recStartTime = Date.now();
mediaRecorder.start(250);
inputDefault.style.display = 'none';
inputRecording.style.display = '';
updateRecTimer();
recTimerInterval = setInterval(updateRecTimer, 1000);
} catch (e) { addMsg('system', 'Mic: ' + e.message); }
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
mediaRecorder.stream.getTracks().forEach(t => t.stop());
}
}
function updateRecTimer() {
const sec = Math.floor((Date.now() - recStartTime) / 1000);
document.getElementById('rec-timer').textContent = `${Math.floor(sec/60)}:${String(sec%60).padStart(2,'0')}`;
}
function drawWaveform() {
const canvas = document.getElementById('rec-waveform');
const ctx = canvas.getContext('2d');
const data = new Uint8Array(recAnalyser.frequencyBinCount);
function draw() {
recAnimFrame = requestAnimationFrame(draw);
recAnalyser.getByteFrequencyData(data);
const w = canvas.width = canvas.clientWidth * (window.devicePixelRatio || 1);
const h = canvas.height = 32 * (window.devicePixelRatio || 1);
ctx.clearRect(0, 0, w, h);
const bars = data.length, barW = w / bars, mid = h / 2;
ctx.fillStyle = 'rgba(245,239,227,0.8)';
for (let i = 0; i < bars; i++) {
const v = data[i] / 255;
const barH = Math.max(2, v * mid * 0.9);
ctx.beginPath();
ctx.roundRect(i * barW + 1, mid - barH, Math.max(1, barW - 2), barH * 2, 1);
ctx.fill();
}
}
draw();
}
// ════════════════════════════════════════════════════════════════
// SEND MESSAGE
// ════════════════════════════════════════════════════════════════
let sending = false;
window.sendMessage = async function () {
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text || sending) return;
input.value = '';
sending = true;
document.getElementById('send-btn').disabled = true;
addMsg('user', text);
animateThinking();
try {
const raw = await askAI(text);
const actionMatch = raw.match(/\[ACTION:(wave|nod|shake|dance|sleep|wake|excited|thinking)\]/i);
const cleanReply = raw.replace(/\[ACTION:\w+\]/gi, '').trim();
addMsg('bot', cleanReply);
animateSpeaking();
const speakDone = speak(cleanReply);
if (actionMatch) fireAction(actionMatch[1].toLowerCase());
await speakDone;
animateIdle();
interactionCount++;
const mem = customerMemory[CFG.customerName];
if (mem) {
mem.interactions.push(`Asked: "${text.slice(0, 60)}" β€” Rec: "${cleanReply.slice(0, 80)}"`);
if (mem.interactions.length > 20) mem.interactions = mem.interactions.slice(-20);
saveCustomerMemory();
if (interactionCount % 3 === 0) {
addMsg('storage-event', `0G Storage: Profile updated (${mem.interactions.length} saved)`);
}
}
if (chainWallet && interactionCount % 2 === 0) logOnChain(CFG.customerName, text, cleanReply);
} catch (e) {
addMsg('system', 'Error: ' + e.message);
animateIdle();
}
sending = false;
document.getElementById('send-btn').disabled = false;
};
document.getElementById('chat-input')?.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
// ════════════════════════════════════════════════════════════════
// ROBOT ANIMATIONS
// ════════════════════════════════════════════════════════════════
function fireAction(action) {
const actions = { wave: doWave, nod: doNod, shake: doShake, dance: doDance, sleep: doSleep, wake: doWake, excited: doExcited, thinking: doThinking };
if (actions[action]) actions[action]();
}
function animateThinking() {
if (!robot) return;
robot.setAntennasDeg?.(15, -15);
robot.setHeadRpyDeg?.(5, -5, 0);
}
function animateSpeaking() {
if (!robot) return;
robot.setHeadRpyDeg?.(0, 0, 0);
let i = 0;
const wiggle = setInterval(() => {
if (i++ > 6) { clearInterval(wiggle); return; }
robot.setAntennasDeg?.(i % 2 === 0 ? 15 : -15, i % 2 === 0 ? -15 : 15);
}, 280);
}
function animateIdle() {
if (!robot) return;
robot.setHeadRpyDeg?.(0, 0, 0);
robot.setAntennasDeg?.(0, 0);
}
window.doWave = async function () {
if (!robot) return;
robot.setAntennasDeg?.(60, -60); await sleep(300);
robot.setAntennasDeg?.(-60, 60); await sleep(300);
robot.setAntennasDeg?.(60, -60); await sleep(300);
robot.setAntennasDeg?.(0, 0);
};
window.doNod = async function () {
if (!robot) return;
robot.setHeadRpyDeg?.(0, -15, 0); await sleep(250);
robot.setHeadRpyDeg?.(0, 10, 0); await sleep(250);
robot.setHeadRpyDeg?.(0, -10, 0); await sleep(250);
robot.setHeadRpyDeg?.(0, 0, 0);
};
window.doShake = async function () {
if (!robot) return;
robot.setHeadRpyDeg?.(0, 0, -20); await sleep(200);
robot.setHeadRpyDeg?.(0, 0, 20); await sleep(200);
robot.setHeadRpyDeg?.(0, 0, -20); await sleep(200);
robot.setHeadRpyDeg?.(0, 0, 0);
};
window.doDance = async function () {
if (!robot) return;
for (let i = 0; i < 4; i++) {
robot.setHeadRpyDeg?.(10, 0, 20); robot.setAntennasDeg?.(40, -40); await sleep(300);
robot.setHeadRpyDeg?.(-10, 0, -20); robot.setAntennasDeg?.(-40, 40); await sleep(300);
}
robot.setHeadRpyDeg?.(0, 0, 0); robot.setAntennasDeg?.(0, 0);
};
window.doExcited = async function () {
if (!robot) return;
for (let i = 0; i < 3; i++) {
robot.setAntennasDeg?.(50, -50); robot.setHeadRpyDeg?.(0, -8, 0); await sleep(200);
robot.setAntennasDeg?.(-50, 50); robot.setHeadRpyDeg?.(0, 8, 0); await sleep(200);
}
robot.setHeadRpyDeg?.(0, 0, 0); robot.setAntennasDeg?.(0, 0);
};
window.doThinking = async function () {
if (!robot) return;
robot.setHeadRpyDeg?.(8, -5, 15); robot.setAntennasDeg?.(20, -10); await sleep(600);
robot.setHeadRpyDeg?.(8, -5, -15); robot.setAntennasDeg?.(-10, 20); await sleep(600);
robot.setHeadRpyDeg?.(0, 0, 0); robot.setAntennasDeg?.(0, 0);
};
window.doSleep = function () { robot?.gotoSleep?.(); };
window.doWake = function () { robot?.wakeUp?.(); };
// ════════════════════════════════════════════════════════════════
// HELPERS
// ════════════════════════════════════════════════════════════════
function addMsg(role, text) {
const el = document.createElement('div');
el.className = `msg ${role}`;
el.textContent = text;
const t = document.getElementById('transcript');
t.appendChild(el);
t.scrollTop = t.scrollHeight;
}
function setStatus(text, ok = false) {
const el = document.getElementById('status');
el.textContent = text;
el.className = ok ? 'connected' : (text === 'error' ? 'error' : '');
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
</script>
</body>
</html>