Monorepo for Aesthetic.Computer aesthetic.computer
at main 357 lines 12 kB view raw
1<!DOCTYPE html> 2<html lang="en"> 3<head> 4<meta charset="UTF-8"> 5<meta name="viewport" content="width=device-width, initial-scale=1.0"> 6<title>help</title> 7<link rel="icon" href="https://aesthetic.computer/icon/128x128/prompt.png" type="image/png" /> 8<link rel="stylesheet" href="https://aesthetic.computer/type/webfonts/berkeley-mono-variable.css" /> 9<style> 10:root { 11 --bg: #111; --bg2: #1a1a1a; --fg: #ccc; --fg2: #888; 12 --pink: #ff6b9d; --green: #6bcb77; --cyan: #4ecdc4; 13 --border: #333; 14} 15[data-theme="light"] { 16 --bg: #f4f4f4; --bg2: #fff; --fg: #222; --fg2: #777; 17 --pink: rgb(205, 92, 155); --green: #059669; --cyan: #0891b2; 18 --border: #ccc; 19} 20* { margin: 0; padding: 0; box-sizing: border-box; } 21::-webkit-scrollbar { width: 4px; } 22::-webkit-scrollbar-track { background: transparent; } 23::-webkit-scrollbar-thumb { background: var(--border); } 24html, body { height: 100%; } 25body { 26 font-family: 'Berkeley Mono Variable', monospace; 27 font-size: 14px; line-height: 1.6; 28 background: var(--bg); color: var(--fg); 29 cursor: url('https://aesthetic.computer/aesthetic.computer/cursors/precise.svg') 12 12, auto; 30 -webkit-text-size-adjust: none; 31} 32 33/* ---- LANDING ---- */ 34#landing { 35 display: flex; flex-direction: column; align-items: center; 36 justify-content: center; height: 100%; 37 padding: 2em; gap: 1.5em; text-align: center; 38} 39.logo { font-size: 24px; font-weight: normal; letter-spacing: 3px; } 40.logo span { color: var(--pink); } 41.subtitle { font-size: 12px; color: var(--fg2); line-height: 1.7; max-width: 380px; } 42.subtitle a { color: var(--cyan); text-decoration: none; } 43.subtitle a:hover { text-decoration: underline; } 44.divider { width: 40px; height: 1px; background: var(--border); } 45.steps { text-align: left; max-width: 380px; width: 100%; display: flex; flex-direction: column; gap: 0.8em; } 46.step { font-size: 12px; color: var(--fg2); padding-left: 1.5em; position: relative; } 47.step::before { content: attr(data-n); position: absolute; left: 0; color: var(--pink); font-size: 11px; } 48.btn { 49 font-family: inherit; font-size: 13px; letter-spacing: 1px; 50 padding: 8px 24px; background: var(--bg2); color: var(--fg); 51 border: 1px solid var(--border); cursor: pointer; 52 transition: border-color 0.2s, color 0.2s; 53} 54.btn:hover { border-color: var(--pink); color: var(--pink); } 55#authStatus { font-size: 11px; color: var(--fg2); } 56 57/* ---- CHAT APP ---- */ 58#app { 59 display: none; flex-direction: column; height: 100%; 60} 61#app.visible { display: flex; } 62 63/* header */ 64#header { 65 display: flex; align-items: center; justify-content: space-between; 66 padding: 10px 16px; border-bottom: 1px solid var(--border); 67 flex-shrink: 0; 68} 69#header .logo { font-size: 16px; letter-spacing: 2px; } 70.header-right { display: flex; align-items: center; gap: 12px; } 71#usageBar { font-size: 11px; color: var(--fg2); } 72#handleBadge { font-size: 11px; color: var(--pink); } 73.sign-out-btn { 74 font-family: inherit; font-size: 11px; background: none; 75 border: none; color: var(--fg2); cursor: pointer; padding: 0; 76} 77.sign-out-btn:hover { color: var(--fg); } 78 79/* messages */ 80#messages { 81 flex: 1; overflow-y: auto; padding: 16px; 82 display: flex; flex-direction: column; gap: 16px; 83} 84.msg { display: flex; flex-direction: column; gap: 4px; max-width: 680px; } 85.msg.user { align-self: flex-end; align-items: flex-end; } 86.msg.assistant { align-self: flex-start; align-items: flex-start; } 87.msg-role { font-size: 10px; color: var(--fg2); letter-spacing: 1px; text-transform: uppercase; } 88.msg.user .msg-role { color: var(--pink); } 89.msg-body { 90 font-size: 13px; line-height: 1.65; color: var(--fg); 91 background: var(--bg2); border: 1px solid var(--border); 92 padding: 10px 14px; white-space: pre-wrap; word-break: break-word; 93} 94 95/* typing indicator */ 96#typing { display: none; align-self: flex-start; } 97#typing.visible { display: flex; } 98#typing .msg-body { color: var(--fg2); font-style: italic; } 99 100/* input area */ 101#inputArea { 102 border-top: 1px solid var(--border); padding: 12px 16px; 103 display: flex; gap: 8px; align-items: flex-end; flex-shrink: 0; 104} 105#msgInput { 106 font-family: inherit; font-size: 13px; 107 flex: 1; padding: 8px 12px; resize: none; 108 background: var(--bg2); color: var(--fg); 109 border: 1px solid var(--border); 110 min-height: 38px; max-height: 160px; overflow-y: auto; line-height: 1.5; 111} 112#msgInput:focus { outline: none; border-color: var(--cyan); } 113#msgInput::placeholder { color: var(--fg2); } 114#sendBtn { 115 font-family: inherit; font-size: 12px; letter-spacing: 1px; 116 padding: 8px 16px; height: 38px; 117 background: var(--bg2); color: var(--cyan); 118 border: 1px solid var(--cyan); cursor: pointer; flex-shrink: 0; 119} 120#sendBtn:hover { background: var(--cyan); color: var(--bg); } 121#sendBtn:disabled { opacity: 0.4; cursor: default; background: var(--bg2); color: var(--fg2); border-color: var(--border); } 122 123/* empty state */ 124#emptyState { 125 flex: 1; display: flex; flex-direction: column; 126 align-items: center; justify-content: center; gap: 8px; 127 color: var(--fg2); font-size: 12px; text-align: center; 128 padding: 2em; 129} 130#emptyState .hint { font-size: 11px; color: var(--border); } 131 132@media (max-width: 500px) { 133 #header { padding: 8px 12px; } 134 #messages { padding: 12px; } 135 #inputArea { padding: 8px 12px; } 136} 137</style> 138</head> 139<body> 140 141<!-- Landing --> 142<div id="landing"> 143 <div class="logo"><span>help</span>.aesthetic.computer</div> 144 <div class="divider"></div> 145 <div class="subtitle"> 146 AI assistant for <a href="https://aesthetic.computer" target="_blank">Aesthetic Computer</a>. 147 Ask anything about pieces, KidLisp, or creative computing. 148 </div> 149 <div class="steps"> 150 <div class="step" data-n="1.">Sign in with your <a href="https://aesthetic.computer/handle" target="_blank" style="color:var(--cyan)">@handle</a></div> 151 <div class="step" data-n="2.">Ask anything about AC</div> 152 </div> 153 <button class="btn" id="signInBtn">sign in</button> 154 <div id="authStatus"></div> 155</div> 156 157<!-- Chat App --> 158<div id="app"> 159 <div id="header"> 160 <div class="logo"><span>help</span>.aesthetic.computer</div> 161 <div class="header-right"> 162 <span id="usageBar"></span> 163 <span id="handleBadge"></span> 164 <button class="sign-out-btn" id="signOutBtn">sign out</button> 165 </div> 166 </div> 167 168 <div id="messages"> 169 <div id="emptyState"> 170 <div>ask me anything about aesthetic.computer</div> 171 <div class="hint">pieces · kidlisp · creative computing</div> 172 </div> 173 </div> 174 175 <div id="typing" class="msg assistant"> 176 <div class="msg-body">...</div> 177 </div> 178 179 <div id="inputArea"> 180 <textarea id="msgInput" rows="1" placeholder="ask something..."></textarea> 181 <button id="sendBtn">send</button> 182 </div> 183</div> 184 185<script> 186let auth0Client = null; 187let accessToken = null; 188const chatHistory = []; 189 190async function authFetch(url, opts = {}) { 191 const headers = { ...opts.headers }; 192 if (accessToken) headers.Authorization = 'Bearer ' + accessToken; 193 const resp = await fetch(url, { ...opts, headers }); 194 if (resp.status === 401 && auth0Client) { 195 try { 196 accessToken = await auth0Client.getTokenSilently({ cacheMode: 'off' }); 197 headers.Authorization = 'Bearer ' + accessToken; 198 return fetch(url, { ...opts, headers }); 199 } catch { auth0Client.loginWithRedirect(); } 200 } 201 return resp; 202} 203 204async function init() { 205 document.getElementById('authStatus').textContent = 'loading...'; 206 try { 207 await new Promise((resolve, reject) => { 208 const s = document.createElement('script'); 209 s.src = 'https://cdn.auth0.com/js/auth0-spa-js/2.1/auth0-spa-js.production.js'; 210 s.onload = resolve; s.onerror = reject; 211 document.head.appendChild(s); 212 }); 213 214 const config = await fetch('/auth/config').then(r => r.json()); 215 auth0Client = await window.auth0.createAuth0Client({ 216 domain: config.domain, 217 clientId: config.clientId, 218 cacheLocation: 'localstorage', 219 useRefreshTokens: true, 220 authorizationParams: { redirect_uri: location.origin + location.pathname }, 221 }); 222 223 if (location.search.includes('state=') && location.search.includes('code=')) { 224 await auth0Client.handleRedirectCallback(); 225 window.history.replaceState({}, '', location.pathname); 226 } 227 228 if (await auth0Client.isAuthenticated()) { 229 accessToken = await auth0Client.getTokenSilently(); 230 const me = await authFetch('/auth/me').then(r => r.json()); 231 showApp(me); 232 } else { 233 document.getElementById('authStatus').textContent = ''; 234 document.getElementById('signInBtn').onclick = () => auth0Client.loginWithRedirect(); 235 } 236 } catch (err) { 237 document.getElementById('authStatus').textContent = 'error: ' + err.message; 238 } 239} 240 241async function refreshUsage() { 242 try { 243 const { used, limit } = await authFetch('/api/usage').then(r => r.json()); 244 document.getElementById('usageBar').textContent = `${used}/${limit} today`; 245 } catch {} 246} 247 248function showApp(me) { 249 document.getElementById('landing').style.display = 'none'; 250 document.getElementById('app').classList.add('visible'); 251 document.getElementById('handleBadge').textContent = '@' + (me.handle || '?'); 252 document.getElementById('signOutBtn').onclick = () => 253 auth0Client.logout({ logoutParams: { returnTo: location.origin } }); 254 refreshUsage(); 255 setupChat(); 256} 257 258function addMessage(role, content) { 259 const empty = document.getElementById('emptyState'); 260 if (empty) empty.remove(); 261 const msgs = document.getElementById('messages'); 262 const div = document.createElement('div'); 263 div.className = `msg ${role}`; 264 div.innerHTML = `<div class="msg-role">${role === 'user' ? 'you' : 'help'}</div><div class="msg-body"></div>`; 265 div.querySelector('.msg-body').textContent = content; 266 msgs.appendChild(div); 267 msgs.scrollTop = msgs.scrollHeight; 268 return div.querySelector('.msg-body'); 269} 270 271function setupChat() { 272 const input = document.getElementById('msgInput'); 273 const sendBtn = document.getElementById('sendBtn'); 274 const typing = document.getElementById('typing'); 275 const msgs = document.getElementById('messages'); 276 277 input.addEventListener('input', () => { 278 input.style.height = 'auto'; 279 input.style.height = Math.min(input.scrollHeight, 160) + 'px'; 280 }); 281 input.addEventListener('keydown', (e) => { 282 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } 283 }); 284 sendBtn.onclick = send; 285 286 async function send() { 287 const text = input.value.trim(); 288 if (!text || sendBtn.disabled) return; 289 290 input.value = ''; 291 input.style.height = 'auto'; 292 sendBtn.disabled = true; 293 294 addMessage('user', text); 295 chatHistory.push({ role: 'user', content: text }); 296 297 typing.classList.add('visible'); 298 msgs.appendChild(typing); 299 msgs.scrollTop = msgs.scrollHeight; 300 301 let fullText = ''; 302 let assistantBody = null; 303 304 try { 305 const resp = await authFetch('/api/chat', { 306 method: 'POST', 307 headers: { 'Content-Type': 'application/json' }, 308 body: JSON.stringify({ messages: chatHistory }), 309 }); 310 311 typing.classList.remove('visible'); 312 313 if (!resp.ok) { 314 const err = await resp.json(); 315 addMessage('assistant', err.error || 'something went wrong'); 316 sendBtn.disabled = false; 317 return; 318 } 319 320 const used = resp.headers.get('X-Rate-Limit-Used'); 321 const limit = resp.headers.get('X-Rate-Limit-Total'); 322 if (used && limit) document.getElementById('usageBar').textContent = `${used}/${limit} today`; 323 324 assistantBody = addMessage('assistant', ''); 325 326 const reader = resp.body.getReader(); 327 const decoder = new TextDecoder(); 328 while (true) { 329 const { done, value } = await reader.read(); 330 if (done) break; 331 fullText += decoder.decode(value, { stream: true }); 332 assistantBody.textContent = fullText; 333 msgs.scrollTop = msgs.scrollHeight; 334 } 335 336 chatHistory.push({ role: 'assistant', content: fullText }); 337 } catch (err) { 338 typing.classList.remove('visible'); 339 addMessage('assistant', 'error: ' + err.message); 340 } 341 342 sendBtn.disabled = false; 343 input.focus(); 344 } 345} 346 347// Theme 348if (window.matchMedia('(prefers-color-scheme: light)').matches) 349 document.documentElement.setAttribute('data-theme', 'light'); 350window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e) => { 351 document.documentElement.setAttribute('data-theme', e.matches ? 'light' : ''); 352}); 353 354init(); 355</script> 356</body> 357</html>