- store.py: passwords now PBKDF2-HMAC-SHA256 (390k iters, random salt,
constant-time compare). Legacy unsalted-SHA256 hashes upgrade transparently
on successful login — zero impact on existing users.
- Sessions expire after 30 days (lazy cleanup on token lookup).
- Rate limiting on register (5/hr/IP), login (10/15min per IP AND per
username), chat (30/hr/IP) — SQLite-backed, shared across gunicorn workers.
- Registration now requires 8+ char passwords.
- New /api/chat/stream SSE endpoint: citations first ('meta'), streamed
answer deltas, 'done'. Client auto-falls back to non-streaming.
- nginx: proxy_buffering off for SSE.
538 lines
43 KiB
HTML
538 lines
43 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Astraea — Your Attorney</title>
|
||
<style>
|
||
:root { --bg:#070b1a; --panel:#101832; --panel2:#141d3d; --line:#233055; --text:#e8edfb; --muted:#8b98c4; --gold:#f5d06f; }
|
||
* { box-sizing:border-box; margin:0; padding:0; }
|
||
body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; background:radial-gradient(1200px 700px at 50% -10%,#18254f 0%,var(--bg) 60%); color:var(--text); min-height:100vh; }
|
||
canvas { position:fixed; inset:0; z-index:0; pointer-events:none; }
|
||
.app { position:relative; z-index:1; max-width:1120px; margin:0 auto; padding:0 20px 60px; }
|
||
header { padding:20px 0; display:flex; align-items:center; justify-content:space-between; gap:14px; }
|
||
.brand { display:flex; align-items:center; gap:11px; }
|
||
.logo { width:40px; height:40px; border-radius:11px; background:linear-gradient(135deg,#6ea8ff,#a78bfa); display:grid; place-items:center; font-size:20px; }
|
||
.brand h1 { font-size:20px; font-weight:800; }
|
||
.brand h1 span { color:var(--gold); }
|
||
.top-actions { display:flex; align-items:center; gap:10px; }
|
||
.avatar-sm { width:36px; height:36px; border-radius:50%; display:grid; place-items:center; font-size:17px; color:#fff; cursor:pointer; }
|
||
.btn { border:1px solid var(--line); background:var(--panel2); color:var(--text); border-radius:10px; padding:9px 15px; font-size:13.5px; font-weight:600; cursor:pointer; transition:.15s; }
|
||
.btn:hover { border-color:#3b82f6; }
|
||
.btn.primary { background:linear-gradient(135deg,#3b82f6,#6366f1); border:none; color:#fff; }
|
||
.hero { padding:12px 0 20px; }
|
||
.hero h2 { font-size:clamp(24px,4vw,34px); font-weight:800; letter-spacing:-.5px; }
|
||
.hero p { color:var(--muted); margin-top:8px; font-size:15px; }
|
||
.label { font-size:11.5px; text-transform:uppercase; letter-spacing:2px; color:var(--muted); margin:14px 0 12px; }
|
||
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(235px,1fr)); gap:13px; }
|
||
.card { background:linear-gradient(180deg,var(--panel2),var(--panel)); border:1px solid var(--line); border-radius:15px; padding:16px; cursor:pointer; position:relative; overflow:hidden; transition:.18s; }
|
||
.card::before { content:""; position:absolute; inset:0 0 auto 0; height:3px; background:var(--c,#7dd3fc); }
|
||
.card:hover { transform:translateY(-3px); border-color:var(--c,#7dd3fc); }
|
||
.card.active { border-color:var(--c,#7dd3fc); box-shadow:0 0 0 1px var(--c,#7dd3fc),0 14px 34px rgba(0,0,0,.5); }
|
||
.card .emoji { font-size:27px; }
|
||
.card h3 { font-size:15.5px; font-weight:700; margin:10px 0 3px; }
|
||
.card .tag { font-size:12px; color:var(--c,#7dd3fc); font-weight:600; margin-bottom:6px; }
|
||
.card p { font-size:12.5px; color:var(--muted); line-height:1.45; }
|
||
|
||
.chat-panel { margin-top:30px; background:linear-gradient(180deg,var(--panel2),var(--panel)); border:1px solid var(--line); border-radius:18px; display:flex; flex-direction:column; overflow:hidden; }
|
||
.chat-head { padding:14px 18px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:11px; flex-wrap:wrap; }
|
||
.chat-head .emoji { font-size:24px; }
|
||
.chat-head .who { font-weight:700; font-size:15px; }
|
||
.chat-head .scope { color:var(--muted); font-weight:500; font-size:12px; }
|
||
.chips { margin-left:auto; display:flex; gap:6px; flex-wrap:wrap; }
|
||
.chip { font-size:11.5px; padding:5px 10px; border-radius:999px; border:1px solid var(--line); color:var(--muted); background:transparent; cursor:pointer; white-space:nowrap; }
|
||
.chip.on { color:#06121f; background:var(--c,#7dd3fc); border-color:var(--c,#7dd3fc); font-weight:700; }
|
||
.messages { padding:20px; min-height:220px; max-height:520px; overflow-y:auto; display:flex; flex-direction:column; gap:15px; }
|
||
.msg { max-width:80%; display:flex; flex-direction:column; }
|
||
.msg.user { align-self:flex-end; align-items:flex-end; }
|
||
.msg.agent { align-self:flex-start; }
|
||
.bubble { padding:12px 15px; border-radius:15px; font-size:14.5px; line-height:1.6; white-space:pre-wrap; word-break:break-word; }
|
||
.msg.user .bubble { background:linear-gradient(135deg,#3b82f6,#6366f1); color:#fff; border-bottom-right-radius:4px; }
|
||
.msg.agent .bubble { background:var(--panel2); border:1px solid var(--line); border-bottom-left-radius:4px; }
|
||
.msg .who-label { font-size:11px; color:var(--muted); margin:0 4px 4px; }
|
||
.speak { margin-top:6px; background:none; border:none; color:var(--gold); cursor:pointer; font-size:12px; }
|
||
.citations { margin-top:9px; }
|
||
.cite-toggle { font-size:12px; color:var(--gold); cursor:pointer; background:none; border:none; }
|
||
.cite-list { margin-top:7px; display:none; flex-direction:column; gap:7px; }
|
||
.cite-list.open { display:flex; }
|
||
.cite { background:#0c142e; border:1px solid var(--line); border-radius:9px; padding:9px 11px; font-size:12px; color:var(--muted); }
|
||
.cite b { color:var(--text); display:block; margin-bottom:2px; }
|
||
.typing { display:inline-flex; gap:5px; padding:6px 2px; }
|
||
.typing i { width:7px; height:7px; border-radius:50%; background:var(--muted); animation:bl 1.2s infinite; }
|
||
.typing i:nth-child(2){ animation-delay:.2s; } .typing i:nth-child(3){ animation-delay:.4s; }
|
||
@keyframes bl { 0%,100%{opacity:.25;} 50%{opacity:1;} }
|
||
.composer { border-top:1px solid var(--line); padding:13px 15px; display:flex; gap:9px; align-items:flex-end; }
|
||
.composer textarea { flex:1; resize:none; background:#0c142e; border:1px solid var(--line); color:var(--text); border-radius:11px; padding:11px 13px; font-size:14.5px; font-family:inherit; line-height:1.5; max-height:130px; outline:none; }
|
||
.composer textarea:focus { border-color:var(--c,#7dd3fc); }
|
||
.send { border:none; border-radius:11px; padding:12px 18px; font-size:14px; font-weight:700; color:#06121f; background:var(--c,#7dd3fc); cursor:pointer; }
|
||
.send:disabled { opacity:.45; }
|
||
.tools-row { display:flex; gap:8px; padding:0 15px 12px; flex-wrap:wrap; }
|
||
.tools-row .btn { font-size:12.5px; padding:8px 13px; }
|
||
|
||
.modal-bg { position:fixed; inset:0; background:rgba(3,5,12,.8); z-index:50; display:none; align-items:center; justify-content:center; padding:20px; }
|
||
.modal-bg.open { display:flex; }
|
||
.modal { background:linear-gradient(180deg,var(--panel2),var(--panel)); border:1px solid var(--line); border-radius:18px; width:100%; max-width:620px; max-height:90vh; overflow-y:auto; padding:26px; }
|
||
.modal h2 { font-size:20px; font-weight:800; margin-bottom:4px; }
|
||
.modal .sub { color:var(--muted); font-size:13px; margin-bottom:18px; }
|
||
.modal .close { float:right; background:none; border:none; color:var(--muted); font-size:22px; cursor:pointer; }
|
||
.fld { margin-bottom:15px; }
|
||
.fld label { display:block; font-size:12.5px; color:var(--muted); margin-bottom:6px; }
|
||
.fld input, .fld textarea, .fld select { width:100%; background:#0c142e; border:1px solid var(--line); color:var(--text); border-radius:10px; padding:11px 13px; font-size:14px; outline:none; font-family:inherit; }
|
||
.fld textarea { min-height:110px; resize:vertical; }
|
||
.fld input:focus, .fld textarea:focus { border-color:#6366f1; }
|
||
.avatar-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-bottom:18px; }
|
||
.avatar-opt { aspect-ratio:1; border-radius:50%; display:grid; place-items:center; font-size:22px; color:#fff; cursor:pointer; border:2px solid transparent; }
|
||
.avatar-opt.sel { border-color:#fff; box-shadow:0 0 0 3px rgba(255,255,255,.2); }
|
||
.progbar { height:6px; background:var(--panel2); border-radius:3px; overflow:hidden; margin:14px 0 18px; }
|
||
.progbar > div { height:100%; background:linear-gradient(90deg,#6ea8ff,#a78bfa); border-radius:3px; transition:width .2s; }
|
||
.intake-q { font-size:16px; font-weight:600; margin-bottom:12px; line-height:1.4; }
|
||
.intake-nav { display:flex; justify-content:space-between; margin-top:14px; gap:10px; }
|
||
.intake-nav .btn { flex:1; }
|
||
.modal.wide { width:min(880px,94vw); max-height:90vh; overflow:auto; }
|
||
.file-list { max-height:240px; overflow:auto; display:flex; flex-direction:column; gap:6px; }
|
||
.file-item { display:flex; align-items:center; gap:8px; padding:8px 10px; background:var(--panel2); border-radius:8px; font-size:13px; }
|
||
.file-item .nm { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.file-item .cat { color:var(--muted); font-size:11px; }
|
||
.file-item button { background:none; border:1px solid var(--line); color:var(--text); border-radius:6px; padding:4px 9px; cursor:pointer; font-size:12px; }
|
||
.file-item button:hover { background:var(--line); }
|
||
.switch { display:flex; align-items:center; gap:10px; }
|
||
.switch input { width:auto; }
|
||
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:13px; }
|
||
.doc-result { background:#0c142e; border:1px solid var(--line); border-radius:12px; padding:16px; font-size:13px; white-space:pre-wrap; max-height:50vh; overflow-y:auto; line-height:1.6; margin-top:14px; }
|
||
footer { text-align:center; margin-top:26px; font-size:12px; color:var(--muted); }
|
||
footer a { color:var(--muted); }
|
||
@media (max-width:640px){ .msg{max-width:92%;} .grid2{grid-template-columns:1fr;} .avatar-grid{grid-template-columns:repeat(4,1fr);} }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<canvas id="stars"></canvas>
|
||
<div class="app">
|
||
<header>
|
||
<div class="brand"><div class="logo">⚖️</div><div><h1>ASTRA<span>EA</span></h1></div></div>
|
||
<div class="top-actions">
|
||
<button class="btn" onclick="openDocs()">📄 Documents</button>
|
||
<button class="btn" onclick="openSettings()">⚙️ Settings</button>
|
||
<div class="avatar-sm" id="myAvatar" onclick="openProfile()" style="background:linear-gradient(135deg,#6ea8ff,#a78bfa)">⚖</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="hero">
|
||
<h2>Eight specialists. Your case.</h2>
|
||
<p>Ask anything. Every answer is grounded in Washington statute and verified case law — and it already knows your situation.</p>
|
||
</div>
|
||
|
||
<div class="label">Choose your specialist</div>
|
||
<div class="grid" id="grid"></div>
|
||
|
||
<section class="chat-panel" id="chatPanel">
|
||
<div class="chat-head">
|
||
<div class="emoji" id="headEmoji">🧭</div>
|
||
<div><div class="who" id="headName">Navigator</div><div class="scope" id="headScope">Where to start</div></div>
|
||
<div class="chips" id="chips"></div>
|
||
</div>
|
||
<div class="messages" id="messages"></div>
|
||
<div class="tools-row">
|
||
<button class="btn" onclick="openDocs()">📄 Prep a document</button>
|
||
<button class="btn" onclick="openFill()">📝 Fill a form</button>
|
||
<button class="btn" onclick="openMission()">📨 Send on my behalf</button>
|
||
<button class="btn" onclick="clearConv()">🗑 Clear chat</button>
|
||
</div>
|
||
<div class="composer">
|
||
<button class="btn" id="micBtn" onclick="toggleMic()" title="Speak" style="flex:0 0 auto">🎤</button>
|
||
<textarea id="input" rows="1" placeholder="Ask your attorney…"></textarea>
|
||
<button class="send" id="sendBtn">Ask</button>
|
||
</div>
|
||
</section>
|
||
|
||
<footer>Astraea gives practical legal guidance grounded in Washington law — not a substitute for a licensed attorney. · <a href="https://buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener">☕ Support</a></footer>
|
||
</div>
|
||
|
||
<!-- Profile modal -->
|
||
<div class="modal-bg" id="profileModal"><div class="modal">
|
||
<button class="close" onclick="closeModal('profileModal')">×</button>
|
||
<h2>Your profile</h2><div class="sub">This context shapes every answer — your attorney already knows your situation.</div>
|
||
<div class="fld"><label>Display name</label><input id="pfName" placeholder="Your name"></div>
|
||
<div class="fld"><label>Display picture</label></div>
|
||
<div class="avatar-grid" id="avatarGrid"></div>
|
||
<div class="fld"><label>Or upload your own photo</label><input type="file" id="pfPic" accept="image/*" onchange="uploadPic()"><span id="pfPicPreview"></span></div>
|
||
<div class="fld"><label>About Me — everything your attorney should know</label><button class="btn" style="width:100%;margin-bottom:8px" onclick="openIntake()">✍️ Guided intake — answer questions step by step</button><textarea id="pfAbout" placeholder="…or write it freely here. e.g. Married 2011, two children (ages 9 and 12), separated June 2025, own a home in King County, spouse filed a parenting plan…"></textarea></div>
|
||
<div class="fld"><label>📁 Case documents — upload & organize your paperwork</label>
|
||
<div style="display:flex;gap:8px;margin-bottom:8px"><select id="fileCat" style="width:auto"></select><input type="file" id="fileInput" multiple onchange="uploadFiles()" style="flex:1"></div>
|
||
<div id="fileList" class="file-list"><div class="sub">No documents yet — upload your petition, orders, agreements, and evidence.</div></div>
|
||
</div>
|
||
<button class="btn primary" style="width:100%" onclick="saveProfile()">Save profile</button>
|
||
</div></div>
|
||
|
||
<!-- Guided intake modal -->
|
||
<div class="modal-bg" id="intakeModal"><div class="modal">
|
||
<button class="close" onclick="closeModal('intakeModal')">×</button>
|
||
<h2>Guided intake</h2><div class="sub">Answer a few questions — your attorney uses these as context for every answer.</div>
|
||
<div class="progbar"><div id="intakeBar"></div></div>
|
||
<div class="intake-q" id="intakeQ"></div>
|
||
<input id="intakeA" placeholder="">
|
||
<div class="intake-nav">
|
||
<button class="btn" id="intakeBack" onclick="intakePrev()">← Back</button>
|
||
<button class="btn primary" id="intakeNext" onclick="intakeNext()">Next →</button>
|
||
</div>
|
||
</div></div>
|
||
|
||
<!-- Document viewer modal -->
|
||
<div class="modal-bg" id="viewerModal"><div class="modal wide">
|
||
<button class="close" onclick="closeModal('viewerModal')">×</button>
|
||
<h2 id="viewerTitle"></h2>
|
||
<div id="viewerBody" style="margin-top:12px"></div>
|
||
</div></div>
|
||
|
||
<!-- Fill a form modal -->
|
||
<div class="modal-bg" id="fillModal"><div class="modal">
|
||
<button class="close" onclick="closeModal('fillModal')">×</button>
|
||
<h2>Fill a court form</h2><div class="sub">Your answers are typed onto the official WA form PDF, ready to print and file.</div>
|
||
<div class="fld"><label>Form</label><select id="fillFormSelect"></select></div>
|
||
<button class="btn" style="width:100%;margin-bottom:12px" onclick="autofillForm()">✨ Auto-fill from my info</button>
|
||
<div id="fillFields"></div>
|
||
<button class="btn primary" style="width:100%" onclick="generateForm()">⬇ Generate filled PDF</button>
|
||
</div></div>
|
||
|
||
<!-- Settings modal -->
|
||
<div class="modal-bg" id="settingsModal"><div class="modal">
|
||
<button class="close" onclick="closeModal('settingsModal')">×</button>
|
||
<h2>Settings</h2><div class="sub">Comms channels your attorney can use for missions, and safety.</div>
|
||
<div class="fld"><label>Twilio Account SID</label><input id="twSid" placeholder="AC…"></div>
|
||
<div class="fld"><label>Twilio Auth Token</label><input id="twToken" type="password" placeholder="••••"></div>
|
||
<div class="fld"><label>Twilio From number (E.164)</label><input id="twFrom" placeholder="+14255550123"></div>
|
||
<div class="grid2">
|
||
<div class="fld"><label>SMTP host</label><input id="smHost" placeholder="smtp.gmail.com"></div>
|
||
<div class="fld"><label>SMTP port</label><input id="smPort" value="587"></div>
|
||
</div>
|
||
<div class="grid2">
|
||
<div class="fld"><label>SMTP username</label><input id="smUser"></div>
|
||
<div class="fld"><label>SMTP password</label><input id="smPass" type="password"></div>
|
||
</div>
|
||
<div class="fld"><label>Email "from" address</label><input id="emFrom" placeholder="you@example.com"></div>
|
||
<div class="fld"><label>TTS voice</label><select id="ttsVoice"></select></div>
|
||
<div class="fld switch"><label style="margin:0">🔒 No-contact order in effect</label><input type="checkbox" id="ncOrder"></div>
|
||
<div class="fld"><label>Protected person's name (blocked from contact)</label><input id="ncName" placeholder="Full name of the person you must not contact"></div>
|
||
<div class="fld switch"><label style="margin:0">⚠️ Domestic violence is a factor in my case</label><input type="checkbox" id="dvFactor"></div>
|
||
<div class="fld switch"><label style="margin:0">🛡️ Active protection order in effect (RCW 7.105)</label><input type="checkbox" id="poOrder"></div>
|
||
<div class="fld"><label>Safety notes — threats, incidents, what you're afraid of</label><textarea id="safetyNotes" style="min-height:70px" placeholder="e.g. There has been physical violence. I'm afraid for my safety and the children's safety."></textarea></div>
|
||
<button class="btn primary" style="width:100%" onclick="saveSettings()">Save settings</button>
|
||
</div></div>
|
||
|
||
<!-- Documents modal -->
|
||
<div class="modal-bg" id="docsModal"><div class="modal">
|
||
<button class="close" onclick="closeModal('docsModal')">×</button>
|
||
<h2>Prepare a document</h2><div class="sub">Court-ready Washington divorce paperwork, drafted to your facts.</div>
|
||
<div class="fld"><label>Document</label><select id="docType"></select></div>
|
||
<div class="fld"><label>Specific instructions (optional)</label><textarea id="docInst" placeholder="e.g. I want primary residential time with the children, propose alternate weekends for the other parent."></textarea></div>
|
||
<button class="btn primary" style="width:100%" id="docBtn" onclick="genDoc()">Generate document</button>
|
||
<div class="doc-result" id="docResult" style="display:none"></div>
|
||
</div></div>
|
||
|
||
<!-- Mission modal -->
|
||
<div class="modal-bg" id="missionModal"><div class="modal">
|
||
<button class="close" onclick="closeModal('missionModal')">×</button>
|
||
<h2>Send on your behalf</h2><div class="sub">Your attorney will format this professionally. A no-contact order blocks protected recipients.</div>
|
||
<div class="fld"><label>Type</label><select id="msType"><option value="sms">Text (SMS)</option><option value="email">Email</option><option value="call">Phone call</option></select></div>
|
||
<div class="fld"><label>Recipient (phone E.164 or email)</label><input id="msRecipient" placeholder="+14255550123 or name@example.com"></div>
|
||
<div class="fld" id="msSubjectWrap" style="display:none"><label>Subject</label><input id="msSubject"></div>
|
||
<div class="fld"><label>Message</label><textarea id="msBody" placeholder="What do you want to say?"></textarea></div>
|
||
<button class="btn primary" style="width:100%" onclick="doMission()">Send</button>
|
||
<div id="msStatus" style="margin-top:12px;font-size:13px"></div>
|
||
</div></div>
|
||
|
||
<script>
|
||
const AGENTS = {{ agents | tojson }};
|
||
const token = localStorage.getItem('astraea_token') || '';
|
||
let active = AGENTS[0].id;
|
||
let avatars = [];
|
||
let myProfile = {display_name:'', avatar:0, about_me:''};
|
||
let mySettings = {};
|
||
const history = {};
|
||
AGENTS.forEach(a => history[a.id] = []);
|
||
|
||
// starfield
|
||
const cvs = document.getElementById('stars'), ctx = cvs.getContext('2d');
|
||
let stars = [];
|
||
(function size(){ cvs.width=innerWidth; cvs.height=innerHeight; stars=Array.from({length:120},()=>({x:Math.random()*cvs.width,y:Math.random()*cvs.height,r:Math.random()*1.3+.3,t:Math.random()*6.28,s:Math.random()*.02+.005})); })();
|
||
addEventListener('resize',()=>{cvs.width=innerWidth;cvs.height=innerHeight;});
|
||
(function draw(){ ctx.clearRect(0,0,cvs.width,cvs.height); for(const s of stars){ s.t+=s.s; const a=.3+Math.abs(Math.sin(s.t))*.7; ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,6.28); ctx.fillStyle=`rgba(190,210,255,${a})`; ctx.fill(); } requestAnimationFrame(draw); })();
|
||
|
||
function authHeaders(){ return token ? {'Authorization':'Bearer '+token} : {}; }
|
||
function accent(id){ const a=AGENTS.find(x=>x.id===id); return a?a.accent:'#7dd3fc'; }
|
||
function closeModal(id){ document.getElementById(id).classList.remove('open'); }
|
||
|
||
function renderGrid(){
|
||
const g=document.getElementById('grid'); g.innerHTML='';
|
||
AGENTS.forEach(a=>{ const c=document.createElement('div'); c.className='card'+(a.id===active?' active':''); c.style.setProperty('--c',a.accent); c.innerHTML=`<div class="emoji">${a.emoji}</div><h3>${a.name}</h3><div class="tag">${a.tagline}</div><p>${a.description}</p>`; c.onclick=()=>selectAgent(a.id,true); g.appendChild(c); });
|
||
}
|
||
function renderChips(){
|
||
const c=document.getElementById('chips'); c.innerHTML='';
|
||
AGENTS.forEach(a=>{ const b=document.createElement('button'); b.className='chip'+(a.id===active?' on':''); b.style.setProperty('--c',a.accent); b.textContent=a.emoji+' '+a.name.split(' & ')[0].split(' ')[0]; b.onclick=()=>selectAgent(a.id,false); c.appendChild(b); });
|
||
}
|
||
async function selectAgent(id,scroll){
|
||
active=id; const a=AGENTS.find(x=>x.id===id);
|
||
document.getElementById('headEmoji').textContent=a.emoji;
|
||
document.getElementById('headName').textContent=a.name;
|
||
document.getElementById('headScope').textContent=a.tagline;
|
||
const cp=document.getElementById('chatPanel'); cp.style.setProperty('--c',a.accent); cp.style.borderColor=a.accent+'55';
|
||
renderGrid(); renderChips();
|
||
const m=document.getElementById('messages'); m.innerHTML='';
|
||
if(token && !(history[id]||[]).length){
|
||
try{ const r=await fetch('/api/conversation/'+id,{headers:authHeaders()}); const d=await r.json(); history[id]=d.history||[]; }catch(e){}
|
||
}
|
||
(history[id]||[]).forEach(t=>addBubble(t.role,t.content,t.citations));
|
||
if(scroll) cp.scrollIntoView({behavior:'smooth',block:'start'});
|
||
}
|
||
async function clearConv(){
|
||
if(token){ try{ await fetch('/api/conversation/'+active,{method:'DELETE',headers:authHeaders()}); }catch(e){} }
|
||
history[active]=[]; document.getElementById('messages').innerHTML='';
|
||
}
|
||
let rec=null;
|
||
function toggleMic(){
|
||
const SR=window.SpeechRecognition||window.webkitSpeechRecognition;
|
||
const mb=document.getElementById('micBtn');
|
||
if(!SR){ mb.title='Voice input needs Chrome/Edge'; return; }
|
||
if(rec){ rec.stop(); rec=null; mb.classList.remove('on'); mb.style.borderColor=''; return; }
|
||
rec=new SR(); rec.lang='en-US'; rec.interimResults=false;
|
||
rec.onresult=e=>{ const t=e.results[0][0].transcript; const i=document.getElementById('input'); i.value=(i.value?i.value+' ':'')+t; };
|
||
rec.onend=()=>{ rec=null; mb.classList.remove('on'); mb.style.borderColor=''; };
|
||
rec.onerror=()=>{ rec=null; mb.classList.remove('on'); mb.style.borderColor=''; };
|
||
rec.start(); mb.classList.add('on'); mb.style.borderColor='#f87171';
|
||
}
|
||
function addBubble(role,text,citations){
|
||
const m=document.createElement('div'); m.className='msg '+role;
|
||
const l=document.createElement('div'); l.className='who-label'; l.textContent=role==='user'?'You':(AGENTS.find(x=>x.id===active)?.name||'Attorney');
|
||
const b=document.createElement('div'); b.className='bubble'; b.textContent=text;
|
||
m.appendChild(l); m.appendChild(b);
|
||
if(role==='assistant'){
|
||
const sp=document.createElement('button'); sp.className='speak'; sp.textContent='🔊 Listen'; sp.onclick=()=>speak(text); m.appendChild(sp);
|
||
if(citations&&citations.length){ const cw=document.createElement('div'); cw.className='citations'; const t=document.createElement('button'); t.className='cite-toggle'; t.textContent='🔗 '+citations.length+' source'+(citations.length>1?'s':''); const cl=document.createElement('div'); cl.className='cite-list'; citations.forEach(c=>{ const it=document.createElement('div'); it.className='cite'; it.innerHTML=`<b>${c.title}</b>${c.snippet}<div style="color:var(--gold);font-size:10.5px">${c.source}</div>`; cl.appendChild(it); }); t.onclick=()=>cl.classList.toggle('open'); cw.appendChild(t); cw.appendChild(cl); m.appendChild(cw); }
|
||
}
|
||
document.getElementById('messages').appendChild(m);
|
||
document.getElementById('messages').scrollTop=document.getElementById('messages').scrollHeight;
|
||
}
|
||
function typing(on){ document.querySelectorAll('.typing').forEach(e=>e.remove()); if(!on)return; const m=document.createElement('div'); m.className='msg agent'; const t=document.createElement('div'); t.className='typing'; t.innerHTML='<i></i><i></i><i></i>'; m.appendChild(t); document.getElementById('messages').appendChild(m); }
|
||
|
||
async function speak(text){
|
||
try{ const r=await fetch('/api/speak',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:text.replace(/\n+/g,'. ').substring(0,2500),voice:mySettings.tts_voice||'aria'})}); const b=await r.blob(); new Audio(URL.createObjectURL(b)).play(); }catch(e){}
|
||
}
|
||
function liveBubble(){
|
||
const m=document.createElement('div'); m.className='msg agent';
|
||
const l=document.createElement('div'); l.className='who-label'; l.textContent=(AGENTS.find(x=>x.id===active)?.name||'Attorney');
|
||
const b=document.createElement('div'); b.className='bubble'; b.textContent='';
|
||
m.appendChild(l); m.appendChild(b);
|
||
document.getElementById('messages').appendChild(m);
|
||
document.getElementById('messages').scrollTop=document.getElementById('messages').scrollHeight;
|
||
return {m, b};
|
||
}
|
||
async function send(){
|
||
const t=document.getElementById('input').value.trim(); if(!t)return;
|
||
document.getElementById('input').value='';
|
||
addBubble('user',t); history[active].push({role:'user',content:t});
|
||
typing(true); document.getElementById('sendBtn').disabled=true;
|
||
try{
|
||
const r=await fetch('/api/chat/stream',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify({agent_id:active,message:t,history:history[active].slice(0,-1)})});
|
||
if(!r.ok || !r.body){ // fallback to non-streaming
|
||
const d=await r.json().catch(()=>({})); typing(false);
|
||
if(d.answer){ addBubble('assistant',d.answer,d.citations); history[active].push({role:'assistant',content:d.answer,citations:d.citations}); }
|
||
else addBubble('assistant',d.error||'Something went wrong.',null);
|
||
} else {
|
||
typing(false);
|
||
const live=liveBubble(); let acc=''; let cites=null;
|
||
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
|
||
while(true){
|
||
const {done,value}=await reader.read(); if(done)break;
|
||
buf+=dec.decode(value,{stream:true});
|
||
const parts=buf.split('\n\n'); buf=parts.pop();
|
||
for(const part of parts){
|
||
const ev=(part.match(/^event: (.+)$/m)||[])[1];
|
||
const dataLine=(part.match(/^data: (.+)$/m)||[])[1];
|
||
if(!ev||!dataLine)continue;
|
||
const payload=JSON.parse(dataLine);
|
||
if(ev==='delta'){ acc+=payload.t; live.b.textContent=acc; document.getElementById('messages').scrollTop=document.getElementById('messages').scrollHeight; }
|
||
else if(ev==='done'){ cites=payload.citations; }
|
||
}
|
||
}
|
||
live.m.remove();
|
||
addBubble('assistant',acc,cites); history[active].push({role:'assistant',content:acc,citations:cites});
|
||
}
|
||
}catch(e){ typing(false); addBubble('assistant','Could not reach the engine.',null); }
|
||
finally{ document.getElementById('sendBtn').disabled=false; }
|
||
}
|
||
|
||
async function loadAvatars(){ const r=await fetch('/api/avatars'); avatars=await r.json(); const g=document.getElementById('avatarGrid'); g.innerHTML=''; avatars.forEach(a=>{ const d=document.createElement('div'); d.className='avatar-opt'; d.style.background=`linear-gradient(135deg,${a.c1},${a.c2})`; d.textContent=a.glyph; d.title=a.label; d.onclick=()=>{ myProfile.avatar=a.id; renderAvatarSel(); }; g.appendChild(d); }); }
|
||
function renderAvatarSel(){ document.querySelectorAll('.avatar-opt').forEach((d,i)=>d.classList.toggle('sel',i===myProfile.avatar)); }
|
||
function renderMyAvatar(){ const el=document.getElementById('myAvatar'); if(myProfile.profile_pic){ el.textContent=''; el.style.background=`url('${myProfile.profile_pic}') center/cover`; } else { const a=avatars[myProfile.avatar]||avatars[0]; if(a){ el.style.background=`linear-gradient(135deg,${a.c1},${a.c2})`; el.textContent=a.glyph; } } }
|
||
async function uploadPic(){
|
||
const f=document.getElementById('pfPic').files[0];
|
||
if(!f) return;
|
||
const fd=new FormData(); fd.append('file',f);
|
||
const pv=document.getElementById('pfPicPreview'); pv.textContent='Uploading…';
|
||
try{ const r=await fetch('/api/profile/pic',{method:'POST',headers:authHeaders(),body:fd}); const d=await r.json();
|
||
if(d.error){ pv.textContent=d.error; pv.style.color='#f87171'; return; }
|
||
myProfile=d; renderMyAvatar(); pv.textContent='✓ uploaded'; pv.style.color='#4ade80';
|
||
}catch(e){ pv.textContent='Upload failed'; pv.style.color='#f87171'; }
|
||
}
|
||
const INTAKE=[
|
||
{label:"My full legal name", q:"What's your full legal name (first, middle, last)?", p:"Jane Marie Doe"},
|
||
{label:"My mailing address", q:"What's your current mailing address (street, city, state, ZIP)?", p:"123 Main St, Everett, WA 98201"},
|
||
{label:"Spouse's full legal name", q:"What's your spouse's full legal name?", p:"John Robert Doe"},
|
||
{label:"Spouse's mailing address", q:"What's your spouse's mailing address?", p:"456 Oak Ave, Everett, WA 98201"},
|
||
{label:"Marriage date & place", q:"When and where did you get married (exact date + city/county/state)?", p:"June 15, 2011, Seattle, WA"},
|
||
{label:"Separation date", q:"When did you separate (exact date)?", p:"June 1, 2025"},
|
||
{label:"Case number", q:"Has a case been filed? What's the case number (if you know it)?", p:"Not yet / 24-3-01234-31"},
|
||
{label:"Children (names, ages, DOBs)", q:"List each child's full name, age, and date of birth.", p:"Emma Grace Doe, 12, born 01/15/2013; Liam James Doe, 9, born 03/22/2016"},
|
||
{label:"Current custody", q:"Where do the children live now, and what's the current schedule?", p:"They live with me; dad has them every other weekend"},
|
||
{label:"Home / real estate", q:"Do you own a home or other real estate? Address, when bought, whose name is on the title/mortgage?", p:"House at 123 Main St, bought 2012, both names on title"},
|
||
{label:"Assets", q:"What are your main assets? (bank accounts, retirement, vehicles, business, investments)", p:"Joint savings ~$20k, my 401k ~$85k, two cars"},
|
||
{label:"Debts", q:"What are your main debts? (mortgage, credit cards, loans)", p:"Mortgage $280k remaining, $9k credit card, $12k car loan"},
|
||
{label:"My income & employer", q:"What's your job, employer, and annual gross income?", p:"Registered nurse, Providence, $72k/yr"},
|
||
{label:"Spouse's income & employer", q:"What's your spouse's job, employer, and annual gross income?", p:"Software engineer, Amazon, $95k/yr"},
|
||
{label:"Children's health insurance", q:"Who provides health insurance for the children, and through which employer?", p:"My employer (Providence) covers the kids"},
|
||
{label:"Case status", q:"Has either of you filed for divorce yet, and what does each side want (custody, support, property)?", p:"Spouse filed; wants full custody and to keep the house"},
|
||
{label:"Safety / DV", q:"Is there any domestic violence, abuse, or safety concern? Any protection orders in place?", p:"No / Yes — details"},
|
||
{label:"Other cases", q:"Are there any other court cases involving you, your spouse, or the children (criminal, CPS, other family law)?", p:"None"},
|
||
{label:"Goals & worries", q:"What outcome matters most to you? What are you most worried about?", p:"Keeping the kids with me and keeping the house"},
|
||
];
|
||
let intakeIdx=0; const intakeAnswers={};
|
||
function openIntake(){ intakeIdx=0; for(const k in intakeAnswers) delete intakeAnswers[k]; closeModal('profileModal'); renderIntake(); document.getElementById('intakeModal').classList.add('open'); }
|
||
function renderIntake(){ const q=INTAKE[intakeIdx]; document.getElementById('intakeQ').textContent=(intakeIdx+1)+'. '+q.q; const a=document.getElementById('intakeA'); a.value=intakeAnswers[intakeIdx]||''; a.placeholder=q.p; document.getElementById('intakeBar').style.width=Math.round((intakeIdx/(INTAKE.length-1))*100)+'%'; document.getElementById('intakeBack').style.visibility=intakeIdx===0?'hidden':'visible'; document.getElementById('intakeNext').textContent=intakeIdx===INTAKE.length-1?'Finish ✓':'Next →'; }
|
||
function intakeNext(){ intakeAnswers[intakeIdx]=document.getElementById('intakeA').value.trim(); if(intakeIdx<INTAKE.length-1){ intakeIdx++; renderIntake(); } else { finishIntake(); } }
|
||
function intakePrev(){ intakeAnswers[intakeIdx]=document.getElementById('intakeA').value.trim(); if(intakeIdx>0){ intakeIdx--; renderIntake(); } }
|
||
function finishIntake(){
|
||
const parts=[]; INTAKE.forEach((q,i)=>{ const v=(intakeAnswers[i]||'').trim(); if(v) parts.push(q.label+': '+v); });
|
||
document.getElementById('pfAbout').value=parts.join('\n');
|
||
closeModal('intakeModal'); document.getElementById('profileModal').classList.add('open');
|
||
}
|
||
let myFiles=[], myCats=['Other'];
|
||
function escapeHtml(s){ return (s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||
function fmtSize(n){ return n>1048576?(n/1048576).toFixed(1)+' MB':n>1024?(n/1024).toFixed(0)+' KB':(n||0)+' B'; }
|
||
async function loadFiles(){
|
||
if(!token) return;
|
||
try{ const r=await fetch('/api/files',{headers:authHeaders()}); const d=await r.json(); myFiles=d.files||[]; myCats=d.categories||['Other'];
|
||
const sel=document.getElementById('fileCat'); if(sel){ sel.innerHTML=''; myCats.forEach(c=>{ const o=document.createElement('option'); o.value=c; o.textContent=c; sel.appendChild(o); }); }
|
||
renderFiles();
|
||
}catch(e){}
|
||
}
|
||
function renderFiles(){
|
||
const el=document.getElementById('fileList'); if(!el) return;
|
||
if(!myFiles.length){ el.innerHTML='<div class="sub">No documents yet — upload your petition, orders, agreements, and evidence.</div>'; return; }
|
||
const groups={}; myFiles.forEach(f=>{ (groups[f.category]=groups[f.category]||[]).push(f); });
|
||
el.innerHTML='';
|
||
Object.keys(groups).forEach(cat=>{
|
||
const h=document.createElement('div'); h.style.cssText='color:var(--gold);font-size:12px;font-weight:700;margin-top:10px'; h.textContent=cat+' ('+groups[cat].length+')'; el.appendChild(h);
|
||
groups[cat].forEach(f=>{
|
||
const it=document.createElement('div'); it.className='file-item';
|
||
const nm=document.createElement('span'); nm.className='nm'; nm.textContent=f.orig_name; it.appendChild(nm);
|
||
const sz=document.createElement('span'); sz.className='cat'; sz.textContent=fmtSize(f.size); it.appendChild(sz);
|
||
const v=document.createElement('button'); v.textContent='View'; v.onclick=()=>viewFile(f.id); it.appendChild(v);
|
||
const d=document.createElement('button'); d.textContent='✕'; d.onclick=()=>delFile(f.id); it.appendChild(d);
|
||
el.appendChild(it);
|
||
});
|
||
});
|
||
}
|
||
async function uploadFiles(){
|
||
const fs=document.getElementById('fileInput').files; if(!fs.length) return;
|
||
const cat=document.getElementById('fileCat').value;
|
||
for(const f of fs){ const fd=new FormData(); fd.append('file',f); fd.append('category',cat); try{ await fetch('/api/files',{method:'POST',headers:authHeaders(),body:fd}); }catch(e){} }
|
||
document.getElementById('fileInput').value='';
|
||
await loadFiles();
|
||
}
|
||
async function delFile(id){ try{ await fetch('/api/files/'+id,{method:'DELETE',headers:authHeaders()}); }catch(e){} await loadFiles(); }
|
||
async function viewFile(id){
|
||
const rec=myFiles.find(f=>f.id===id); if(!rec) return;
|
||
const ext=(rec.orig_name.split('.').pop()||'').toLowerCase();
|
||
document.getElementById('viewerTitle').textContent=rec.orig_name;
|
||
const body=document.getElementById('viewerBody'); body.innerHTML='<div class="sub">Loading…</div>';
|
||
document.getElementById('viewerModal').classList.add('open');
|
||
if(['png','jpg','jpeg','gif','webp','svg'].includes(ext)){
|
||
const r=await fetch('/api/files/'+id+'/raw',{headers:authHeaders()}); const b=await r.blob(); const u=URL.createObjectURL(b);
|
||
body.innerHTML='<img src="'+u+'" style="max-width:100%;border-radius:8px">';
|
||
} else if(ext==='pdf'){
|
||
const r=await fetch('/api/files/'+id+'/raw',{headers:authHeaders()}); const b=await r.blob(); const u=URL.createObjectURL(b);
|
||
body.innerHTML='<iframe src="'+u+'" style="width:100%;height:70vh;border:0;border-radius:8px"></iframe>';
|
||
} else {
|
||
let txt=''; try{ const r=await fetch('/api/files/'+id+'/text',{headers:authHeaders()}); const d=await r.json(); txt=d.text||''; }catch(e){}
|
||
body.innerHTML='<pre style="white-space:pre-wrap;font-family:inherit;font-size:13px;line-height:1.55">'+escapeHtml(txt||'(no text preview — this file type downloads instead)')+'</pre>';
|
||
}
|
||
}
|
||
function openProfile(){ document.getElementById('pfName').value=myProfile.display_name||''; document.getElementById('pfAbout').value=myProfile.about_me||''; renderAvatarSel(); document.getElementById('profileModal').classList.add('open'); loadFiles(); }
|
||
let allForms=[];
|
||
async function openFill(){
|
||
try{ const r=await fetch('/api/forms'); allForms=await r.json();
|
||
const sel=document.getElementById('fillFormSelect'); sel.innerHTML='';
|
||
allForms.forEach(f=>{ const o=document.createElement('option'); o.value=f.id; o.textContent=f.name; sel.appendChild(o); });
|
||
document.getElementById('fillFields').innerHTML='<div class="sub">Click "Auto-fill from my info" to pre-fill the fields.</div>';
|
||
document.getElementById('fillModal').classList.add('open');
|
||
}catch(e){}
|
||
}
|
||
async function autofillForm(){
|
||
const fid=document.getElementById('fillFormSelect').value;
|
||
const el=document.getElementById('fillFields'); el.innerHTML='<div class="sub">Filling…</div>';
|
||
const r=await fetch('/api/forms/autofill',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify({form_id:fid})});
|
||
const d=await r.json();
|
||
const f=allForms.find(x=>x.id===fid);
|
||
el.innerHTML='';
|
||
(f.fields||[]).forEach(fd=>{
|
||
const row=document.createElement('div'); row.style.cssText='margin-bottom:8px';
|
||
const lb=document.createElement('label'); lb.style.cssText='font-size:12px;color:var(--muted)'; lb.textContent=fd.label; row.appendChild(lb);
|
||
const inp=document.createElement('input'); inp.dataset.key=fd.key; inp.value=(d.values||{})[fd.key]||''; row.appendChild(inp);
|
||
el.appendChild(row);
|
||
});
|
||
}
|
||
async function generateForm(){
|
||
const fid=document.getElementById('fillFormSelect').value;
|
||
const values={}; document.querySelectorAll('#fillFields input').forEach(i=>values[i.dataset.key]=i.value);
|
||
const r=await fetch('/api/forms/fill',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify({form_id:fid,values})});
|
||
if(!r.ok){ alert('Could not generate form'); return; }
|
||
const blob=await r.blob(); const url=URL.createObjectURL(blob);
|
||
const a=document.createElement('a'); a.href=url; a.download=fid+'_filled.pdf'; document.body.appendChild(a); a.click(); a.remove();
|
||
}
|
||
async function saveProfile(){
|
||
const body={display_name:document.getElementById('pfName').value, about_me:document.getElementById('pfAbout').value, avatar:myProfile.avatar};
|
||
const r=await fetch('/api/profile',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify(body)});
|
||
myProfile=await r.json(); renderMyAvatar(); closeModal('profileModal');
|
||
}
|
||
function openSettings(){
|
||
const s=mySettings; document.getElementById('twSid').value=s.twilio_sid||''; document.getElementById('twToken').value=''; document.getElementById('twFrom').value=s.twilio_from||''; document.getElementById('smHost').value=s.smtp_host||''; document.getElementById('smPort').value=s.smtp_port||587; document.getElementById('smUser').value=s.smtp_user||''; document.getElementById('emFrom').value=s.email_from||''; document.getElementById('ttsVoice').value=s.tts_voice||'aria'; document.getElementById('ncOrder').checked=!!s.no_contact_order; document.getElementById('ncName').value=s.partner_name||''; document.getElementById('dvFactor').checked=!!s.domestic_violence; document.getElementById('poOrder').checked=!!s.protection_order; document.getElementById('safetyNotes').value=s.safety_notes||'';
|
||
document.getElementById('settingsModal').classList.add('open');
|
||
}
|
||
async function saveSettings(){
|
||
const body={ twilio_sid:document.getElementById('twSid').value, twilio_from:document.getElementById('twFrom').value, smtp_host:document.getElementById('smHost').value, smtp_port:parseInt(document.getElementById('smPort').value||587), smtp_user:document.getElementById('smUser').value, email_from:document.getElementById('emFrom').value, tts_voice:document.getElementById('ttsVoice').value, no_contact_order:document.getElementById('ncOrder').checked?1:0, partner_name:document.getElementById('ncName').value, domestic_violence:document.getElementById('dvFactor').checked?1:0, protection_order:document.getElementById('poOrder').checked?1:0, safety_notes:document.getElementById('safetyNotes').value };
|
||
const tw=document.getElementById('twToken').value; if(tw) body.twilio_auth_token=tw;
|
||
const sp=document.getElementById('smPass').value; if(sp) body.smtp_pass=sp;
|
||
const r=await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify(body)});
|
||
mySettings=await r.json(); closeModal('settingsModal');
|
||
}
|
||
async function openDocs(){ const r=await fetch('/api/documents/types'); const types=await r.json(); const s=document.getElementById('docType'); s.innerHTML=''; types.forEach(t=>{ const o=document.createElement('option'); o.value=t.id; o.textContent=t.name+' ('+t.form+')'; s.appendChild(o); }); document.getElementById('docResult').style.display='none'; document.getElementById('docsModal').classList.add('open'); }
|
||
async function genDoc(){
|
||
document.getElementById('docBtn').disabled=true;
|
||
const body={doc_type:document.getElementById('docType').value, instructions:document.getElementById('docInst').value};
|
||
const r=await fetch('/api/documents',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify(body)});
|
||
const d=await r.json(); document.getElementById('docBtn').disabled=false;
|
||
const el=document.getElementById('docResult'); el.style.display='block'; el.textContent=d.document||d.error;
|
||
}
|
||
function openMission(){ document.getElementById('missionModal').classList.add('open'); }
|
||
document.getElementById('msType').onchange=function(){ document.getElementById('msSubjectWrap').style.display=this.value==='email'?'block':'none'; };
|
||
async function doMission(){
|
||
const type=document.getElementById('msType').value, recipient=document.getElementById('msRecipient').value;
|
||
const body={type,recipient,message:document.getElementById('msBody').value,subject:document.getElementById('msSubject').value,body:document.getElementById('msBody').value};
|
||
const r=await fetch('/api/mission',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify(body)});
|
||
const d=await r.json(); document.getElementById('msStatus').textContent=d.ok?'✓ Sent.':('✗ '+(d.error||'Failed')); document.getElementById('msStatus').style.color=d.ok?'#4ade80':'#fca5a5';
|
||
}
|
||
|
||
document.getElementById('sendBtn').onclick=send;
|
||
document.getElementById('input').addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();} });
|
||
|
||
async function boot(){
|
||
if(!token){ window.location.href='/'; return; }
|
||
try{ const r=await fetch('/api/me',{headers:authHeaders()}); if(!r.ok){ window.location.href='/'; return; } const d=await r.json(); myProfile=d.profile; mySettings=d.settings; }catch(e){}
|
||
await loadAvatars(); renderMyAvatar(); renderGrid(); renderChips(); selectAgent(active,false);
|
||
// populate tts voices
|
||
try{ const vr=await fetch('/api/voices'); const vd=await vr.json(); const s=document.getElementById('ttsVoice'); s.innerHTML=''; vd.voices.forEach(v=>{ const o=document.createElement('option'); o.value=v.id; o.textContent=v.label; s.appendChild(o); }); s.value=mySettings.tts_voice||'aria'; }catch(e){}
|
||
}
|
||
boot();
|
||
</script>
|
||
</body>
|
||
</html>
|