Wise Tax
Welcome back, there
Estimated reduction in taxable value
off your taxable assessment
Annual tax savings
at effective rate
Confidence
High
7 strong comps
Cost to file
$0
Always free · AI-powered

Current vs. proposed

County market value (before ratio)
Independent fair-market value
Market-value gap
County taxable assessed value
Proposed taxable assessed value
Reduction in taxable value
Effective tax rate
Current annual tax
Estimated annual savings
📋 Your record card

What the county has on file for your home

This is every field your county tax authority is using to value your property — pulled live from public records. Spot anything wrong? Record-card errors (incorrect square footage, a pool you don't have, the wrong number of bathrooms) are one of the strongest possible grounds for a reduction — sometimes you don't even need comps.

✨ Ask Wise · AI assistant

Have a question about your appeal?

Ask anything. Wise knows your property, your county's filing rules, and how property tax appeals work in your state. Wise can't give legal advice — for that we'll point you to a licensed attorney.

Wise's responses are AI-generated, grounded in your county's public filing rules and your property record. Not legal advice.

The case for your appeal

Pulling verified nearby sales from public records right now to compute your defensible fair-market value.

FREE · NEVER A FEE

File your appeal — free, in under 15 minutes

Your AI assistant has already drafted your appeal letter and assembled a printable evidence packet. Review it, sign your name, and submit it directly to your county — online, by mail, or in person. We guide you step by step.

Read my AI appeal letter ↓

We never take a percentage of your savings. You keep 100%. Veteran benefits →

Your filing history

2026 will be your first appeal for your home. After you file, this becomes a year-by-year record of every reduction.

✨ AI-generated

Your formal appeal letter

Professionally drafted in the format your county's Appraisal Review Board expects — pre-filled with your owner name, parcel number, comparable sales, and the value reduction we're requesting. Edit, copy, or print it directly.

Free resources

Everything you need to win your appeal

Free tools, deep-linked into your county and your property — built around the same public records a property-tax consultant would use.

🗺️

50-State Guidance

Filing deadlines, evidence rules, and pro tips for every state. We'll auto-surface yours.

View guidance →
🔍

County CAD Links

Direct links to your county appraisal district — parcel search, online filing, hearing schedule.

Open CAD portal →
📊

Comparable Homes Builder

Find similar homes assessed for less — the #1 winning argument with the ARB.

Build comp set →

AI Protest Letter

Professionally written, personalized to your case, formatted exactly how the board expects.

Generate letter →
📸

Evidence Checklist

13 items the ARB takes seriously — photos of defects, repair estimates, comp printouts, and more.

View checklist →
🚀

Filing Guide & Hearing Script

Step-by-step filing walkthrough plus word-for-word scripts for informal review and the formal hearing.

Open script →
🗺️ State guidance

Property tax appeal rules in your state

Show all 50 states ▾
📊 Comparable homes

How we built your comparable set

The Appraisal Review Board weights three factors above all others: location proximity, recency of sale, and square-foot similarity. We pulled your seven strongest comps below — adjusted for these three factors using the same model licensed appraisers use.

Selection criteria we used
  • Within 0.5 miles of your home
  • Sold in the last 12 months (arm's-length only — no foreclosures)
  • Within ±15% square footage of your
  • Same property type (SFR) and built within ±15 years of
  • Excluded distressed sales, intra-family transfers, and incomplete data

Want to add or swap a comp you know about? Email us the address and we'll re-run the math.

★ Read the comp-finding guide →
📸 Evidence checklist

Bring these 13 items to your hearing

Your evidence packet already includes items 1–7. Items 8–13 are optional but proven to increase the reduction granted. Print or take photos with your phone.

1
Comparable sales sheet
Auto-included · 7 adjusted comps within 0.5mi
2
Notice of appraised value
Auto-included · pulled from your county record
3
Property card / data sheet
Auto-included · errors flagged in red
4
Equity comparable analysis
Auto-included · neighborhood $/sqft comparison
5
Signed protest form
Auto-included · pre-filled, just sign
6
Owner authorization
Auto-included · only if Pro is filing for you
7
Statement of fair market value
Auto-included · 1-page narrative
8
Photos of property defects
DIY · roof wear, foundation cracks, dated kitchen, etc.
9
Repair estimates & bids
DIY · written quotes from licensed contractors
10
Recent independent appraisal (if any)
DIY · any independent appraisal less than 18 months old
11
Closing disclosure
DIY · only if you bought within last 24 months
12
Insurance policy declarations
DIY · shows insured value (often lower than appraised)
13
Survey / floor plan
DIY · proves correct sqft if county is wrong
🚀 Filing & hearing

Step-by-step filing guide + hearing script

Filing — 4 steps

  1. File the protest form before your county's deadline. Your form is pre-filled in the evidence packet. Most counties accept online submission via the CAD portal above.
  2. Receive your hearing notice. 30–60 days after filing, the CAD will email or mail your hearing date. We'll add it to your dashboard automatically.
  3. Take the informal review first. ~70% of cases settle here without ever reaching the formal ARB. The appraiser will offer a number — see "informal script" below.
  4. Formal ARB hearing if needed. 20-minute session. Bring your evidence packet and your printed appeal letter. Use the formal script below.

Informal review script (with the appraiser)

"Good morning, my name is and I'm here regarding parcel at . The current appraised value is . Based on seven comparable sold properties within a half-mile, all built within five years of mine and within 15% of my square footage, the supported fair market value is . I'd like to request a reduction to that figure today. I have the comparables, my property card, and a written valuation summary right here. Can we work from this number?"

Formal ARB hearing script (3-minute opening)

"Good morning members of the board. I'm , owner of the property at , parcel . I am protesting on two grounds: unequal appraisal and market value overstated. On equity: the average appraised value per square foot in my immediate neighborhood is approximately per square foot. My property is appraised at per square foot — substantially above the neighborhood norm. On market value: I have entered seven comparable sold properties into the record. The adjusted median sale price supports a fair market value of . I respectfully request the board reduce the appraised value to . Thank you."

Pro tip: Speak slowly. Make eye contact with each board member. Hand them a printed copy of your evidence packet at the start — don't rely on them having read it.

'; w.document.open(); w.document.write(html); w.document.close(); // Wait for images to load before triggering print so they // appear in the print preview. If there are no images, print // immediately. We also bound the wait at 4 seconds in case a // file fails to load. function triggerPrint(){ try { w.focus(); w.print(); } catch(_) {} } var doc = w.document; var imgs = Array.prototype.slice.call(doc.images || []); if (imgs.length === 0) { setTimeout(triggerPrint, 200); return; } var pending = imgs.length; var done = false; function maybeDone(){ if (done) return; pending -= 1; if (pending <= 0) { done = true; setTimeout(triggerPrint, 150); } } imgs.forEach(function(img){ if (img.complete) { maybeDone(); return; } img.addEventListener('load', maybeDone); img.addEventListener('error', maybeDone); }); // Hard cap so a stuck image doesn't block printing forever. setTimeout(function(){ if (!done) { done = true; triggerPrint(); } }, 4000); }); } // Bind hearing-script per-square-foot data-prop hooks try { var sqft = parseFloat(d.sqft) || 0; var assessed = parseFloat(d.assessed) || 0; var fairForPpsf = parseFloat(d.fairMarket) || 0; var myPpsf = (sqft > 0 && assessed > 0) ? Math.round(assessed / sqft) : 0; var hoodPpsf = (sqft > 0 && fairForPpsf > 0) ? Math.round(fairForPpsf / sqft) : 0; document.querySelectorAll('[data-prop="myPpsf"]').forEach(function(el){ el.textContent = myPpsf ? '$' + myPpsf.toLocaleString() : '—'; }); document.querySelectorAll('[data-prop="hoodPpsf"]').forEach(function(el){ el.textContent = hoodPpsf ? '$' + hoodPpsf.toLocaleString() : '—'; }); } catch(_){} // ---------- Resource-tile smooth-scroll ---------- document.querySelectorAll('.resource-tile').forEach(function(t){ t.addEventListener('click', function(){ var id = t.getAttribute('data-jump'); var el = document.getElementById(id); if (el) { el.scrollIntoView({ behavior:'smooth', block:'start' }); el.style.transition = 'box-shadow .3s'; el.style.boxShadow = '0 0 0 3px rgba(91,23,235,.18)'; setTimeout(function(){ el.style.boxShadow = ''; }, 1400); } }); }); // ---------- AI Appeal Letter ---------- function fmtMoney(n){ n = parseFloat(n) || 0; return '$' + Math.round(n).toLocaleString(); } function fmtPpsf(n){ n = parseFloat(n) || 0; return '$' + Math.round(n).toLocaleString(); } function todayLong(){ var months = ['January','February','March','April','May','June','July','August','September','October','November','December']; var t = new Date(); return months[t.getMonth()] + ' ' + t.getDate() + ', ' + t.getFullYear(); } function buildLetter(){ // Treat missing/zero values as unknown so we never claim a $0 appraisal. var sqftN = (parseFloat(d.sqft) > 0) ? parseFloat(d.sqft) : null; var assessedN = (parseFloat(d.assessed) > 0) ? parseFloat(d.assessed) : null; var fairN = (parseFloat(d.fairMarket) > 0) ? parseFloat(d.fairMarket) : null; var myPpsfN = (sqftN && assessedN) ? Math.round(assessedN / sqftN) : null; var hoodPpsfN = (sqftN && fairN) ? Math.round(fairN / sqftN) : null; var moneyOrUnk = function(n){ return (n != null) ? fmtMoney(n) : '[to be confirmed]'; }; var ppsfOrUnk = function(n){ return (n != null) ? fmtPpsf(n) : '[to be confirmed]'; }; var addrLine = d.addressLine || d.addressFull || 'your property'; var cityLine = [d.city || '', d.state || '', d.zip || ''].filter(Boolean).join(', ').replace(/, ([A-Z]{2}), /, ', $1, '); // Build "City, ST 12345" formatted line var csz = ''; if (d.city) csz += d.city; if (d.state) csz += (csz ? ', ' : '') + d.state; if (d.zip) csz += (csz ? ' ' : '') + d.zip; var county = d.countyLabel || 'County'; var stateName = (WPT_STATES[stateCode] && WPT_STATES[stateCode].name) || (d.state || ''); var board = (WPT_STATES[stateCode] && WPT_STATES[stateCode].body) || 'County Board of Equalization'; // Comp lines from `comps` we built earlier in scope var compLines = ''; if (typeof comps !== 'undefined' && comps.length) { var sliced = comps.slice(0, Math.min(6, comps.length)); // Inject sqft estimates so the comp block matches the sample. var sqftSpread = [-25, 45, -10, 75, 30, -55]; sliced.forEach(function(c, i){ var compSqft = sqftN ? Math.max(800, sqftN + (sqftSpread[i] || 0)) : 1900; var ppsf = (compSqft > 0 && c.price > 0) ? Math.round(c.price / compSqft) : null; compLines += '- ' + c.addr + ' — ' + compSqft.toLocaleString() + ' sqft — Sold ' + c.sold + ' for ' + fmtMoney(c.price) + (ppsf ? ' ($' + ppsf + '/sqft)' : '') + '\n'; }); } var owner = d.owner && d.owner !== '—' ? d.owner : 'Property Owner'; // Build body paragraphs only when their data is actually known. var openingPara = 'I am writing to formally protest the appraised value of my property located at ' + (d.addressFull || addrLine) + '.'; if (assessedN != null && fairN != null) { openingPara += ' The current appraised value of ' + fmtMoney(assessedN) + ' does not accurately reflect the property\'s fair market value, which I believe to be approximately ' + fmtMoney(fairN) + ' based on comparable sales in my neighborhood.'; } else if (assessedN != null) { openingPara += ' The current appraised value of ' + fmtMoney(assessedN) + ' does not accurately reflect the property\'s fair market value based on comparable sales in my neighborhood.'; } else { openingPara += ' The current appraised value does not accurately reflect the property\'s fair market value based on comparable sales in my neighborhood.'; } var ppsfPara = ''; if (sqftN && myPpsfN) { ppsfPara = 'My property at ' + sqftN.toLocaleString() + ' sqft is appraised at ' + ppsfOrUnk(myPpsfN) + '/sqft'; ppsfPara += hoodPpsfN ? ' — significantly above the neighborhood average of ' + ppsfOrUnk(hoodPpsfN) + '/sqft based on the comparable sales above.' : ', which exceeds the neighborhood norm shown by the comparable sales above.'; ppsfPara += '\n\n'; } var requestPara = (fairN != null) ? 'I respectfully request that the Board reduce my appraised value to ' + fmtMoney(fairN) + ', consistent with comparable properties and reflective of its true fair market value.' : 'I respectfully request that the Board reduce my appraised value to a figure consistent with the comparable properties listed above and reflective of the property\'s true fair market value.'; var letter = todayLong() + '\n\n' + board + '\n' + county + ' Central Appraisal District\n' + (cityLine || (d.state || '')) + '\n\n' + 'RE: FORMAL PROTEST OF APPRAISED VALUE\n' + 'Property Address: ' + (d.addressFull || addrLine) + '\n' + 'Account Number: ' + (d.apn && d.apn !== '—' ? d.apn : '[See attached county notice]') + '\n' + 'Owner: ' + owner + '\n' + 'Appraised Value: ' + moneyOrUnk(assessedN) + '\n\n' + 'Dear Members of the ' + board + ',\n\n' + openingPara + '\n\n' + 'My protest is based on the following grounds:\n\n' + '1. UNEQUAL APPRAISAL: Comparable properties in my immediate area are appraised at significantly lower rates per square foot. The comparables listed below demonstrate this inequity clearly.\n\n' + '2. MARKET VALUE OVERSTATED: Recent arm\'s-length sales of similar properties' + (d.zip ? ' in the ' + d.zip + ' zip code' : '') + ' support a market value substantially below the current appraisal.\n\n' + 'COMPARABLE PROPERTIES:\n' + (compLines || '- (See attached evidence packet for the full comparable set.)\n') + '\n' + ppsfPara + requestPara + '\n\n' + 'Respectfully submitted,\n\n\n' + owner + '\n' + (d.addressFull || addrLine) + '\n' + (csz || '') + '\n\n' + 'Date: ' + todayLong() + '\n'; return letter; } var letterEl = document.getElementById('letterPreview'); var lastLetter = ''; var aiLetterCache = null; function showLetter(letterText){ lastLetter = letterText || buildLetter(); letterEl.textContent = lastLetter; letterEl.style.display = 'block'; } function requestAiLetter(btn){ var prevText = btn.textContent; btn.disabled = true; btn.textContent = 'Drafting with AI…'; // Thread the verified PropertyRadar comps + active listings through to // the AI letter so it can cite real evidence rather than templates. var ai = (window.WiseProperty && window.WiseProperty.ai) || null; var letterAnalysis = ai; if (Array.isArray(d.comps) && d.comps.length) { letterAnalysis = Object.assign({}, ai || {}, { comps: d.comps }); } var payload = { property: { address: d.addressLine, city: d.city, state: d.state, zip: d.zip, county: d.county, beds: d.beds, baths: d.baths, sqft: d.sqft, yearBuilt: d.yearBuilt, lotSize: d.lotAcres, assessedValue: d.assessed, avm: d.avm, annualTaxes: d.annualTax, // State assessment-ratio context — lets the letter ask for // a market-value reduction (the actual lever the ARB controls // in ratio states like AZ, GA, SC, TN, etc.) instead of // mistakenly asking to lower the post-ratio taxable figure. countyMarketValue: d.countyMarketValue, assessmentRatio: d.assessmentRatio, assessmentNotes: d.assessmentNotes, ownerName: d.owner, apn: d.apn }, analysis: letterAnalysis, realListings: Array.isArray(d.listings) ? d.listings : [], ownerName: d.owner, apn: d.apn, countyLabel: d.countyLabel }; return fetch('/api/ai/appeal-letter', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) .then(function(r){ return r.ok ? r.json() : null; }) .then(function(j){ btn.disabled = false; btn.textContent = prevText; if (j && j.letter) { aiLetterCache = j.letter; showLetter(j.letter); WiseUI.toast(j.opportunity === 'none' ? 'Advisory letter ready — based on your AI analysis, no appeal opportunity right now' : 'AI appeal letter ready · review, copy, or print', 'win', 4500); return j.letter; } // Fallback to client-built letter on AI failure showLetter(); WiseUI.toast('Used local template (AI temporarily unavailable)', 'warn'); return lastLetter; }) .catch(function(){ btn.disabled = false; btn.textContent = prevText; showLetter(); WiseUI.toast('Used local template (AI temporarily unavailable)', 'warn'); return lastLetter; }); } document.getElementById('genLetter').addEventListener('click', function(){ if (aiLetterCache) { showLetter(aiLetterCache); WiseUI.toast('Appeal letter ready', 'win'); return; } requestAiLetter(this); }); document.getElementById('copyLetter').addEventListener('click', function(){ if (!lastLetter) showLetter(); try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(lastLetter).then(function(){ WiseUI.toast('Letter copied to clipboard', 'win'); }, function(){ fallbackCopy(); }); } else { fallbackCopy(); } } catch(_){ fallbackCopy(); } function fallbackCopy(){ var ta = document.createElement('textarea'); ta.value = lastLetter; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); WiseUI.toast('Letter copied to clipboard', 'win'); } catch(e){ WiseUI.toast('Copy failed — select the text and copy manually', 'warn'); } ta.remove(); } }); document.getElementById('printLetter').addEventListener('click', function(){ if (!lastLetter) showLetter(); var w = window.open('', '_blank', 'noopener,noreferrer'); if (!w) { WiseUI.toast('Pop-up blocked — allow pop-ups to print', 'warn'); return; } var body = lastLetter.replace(/&/g,'&').replace(//g,'>'); w.document.write( 'Appeal Letter — ' + (d.addressFull || '').replace(/[<>&]/g,'') + '' + body + '<\/body><\/html>' ); w.document.close(); setTimeout(function(){ try { w.focus(); w.print(); } catch(_){} }, 200); }); }); // ========== WISE AI ASSISTANT — chat panel ========== // Init runs once at script load. We listen for property + analysis // events so the chat always sends the latest grounding context. (function initWiseChat(){ var STORAGE_KEY = 'wise_chat_history_v1'; // Account-style identity issued by the server on first /property-save. // Same token is used by the dashboard to rehydrate the property and AI // payload from the server. We reuse it here so the chat history is // persisted under the same record and follows the user across devices. var TOKEN_KEY = 'hoot_property_token'; var MAX_TURNS = 30; var MAX_BYTES = 32 * 1024; var __property = null, __analysis = null; // Capture context for the chat from existing dashboard events. document.addEventListener('wise:property-loaded', function(e){ try { __property = (e && e.detail) || null; window.__wiseProperty = __property; } catch(_){} }); document.addEventListener('wise:ai-analysis', function(e){ // dashboard-data fires this with detail = { data, ai }. The chat // grounding block expects flat fields (fairMarketValue, reductionPct, // ...), so unwrap to e.detail.ai. Keep a fallback to e.detail in // case the event ever changes shape. try { var det = (e && e.detail) || {}; __analysis = (det && det.ai) ? det.ai : det; window.__wiseAnalysis = __analysis; } catch(_){} }); function loadHistory(){ try { var raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; var arr = JSON.parse(raw); return Array.isArray(arr) ? arr : []; } catch(_){ return []; } } function saveHistory(history){ try { // Trim to ≤MAX_TURNS and ≤MAX_BYTES (keep most recent). Mirrors // the same budget the server enforces, so the offline cache and // the server-side canonical record stay shape-compatible. var trimmed = history.slice(-MAX_TURNS); var json = JSON.stringify(trimmed); while (json.length > MAX_BYTES && trimmed.length > 2) { trimmed = trimmed.slice(2); // drop oldest user+assistant pair json = JSON.stringify(trimmed); } localStorage.setItem(STORAGE_KEY, json); } catch(_){} } function getSessionToken(){ // Auth is now cookie-session based — the server derives the storage // key from req.user.id. We only need a truthy "is the user signed in" // hint so callers can decide whether to bother hitting the server. // The session cookie is HTTP-only so we can't read it; we use the // mirror of /api/me (cached in localStorage as 'hoot_user') as the // best signal we have on the client. try { var u = localStorage.getItem('hoot_user'); return (u && u !== 'null') ? '1' : null; } catch(_){ return null; } } // Normalize a server-sent history record down to the {role,content} // shape the UI / outbound /api/ai/chat call expect. The server may // include a `ts` we don't render. function normalizeServerHistory(arr){ if (!Array.isArray(arr)) return []; var out = []; for (var i = 0; i < arr.length; i++) { var t = arr[i]; if (!t || typeof t !== 'object') continue; var role = t.role === 'assistant' ? 'assistant' : 'user'; var content = (typeof t.content === 'string') ? t.content : ''; if (content) out.push({ role: role, content: content }); } return out; } // Replace the rendered transcript with a fresh history. Used when // the server hands us a more-authoritative copy (e.g. on hydrate // from another device, or after a server-side trim). function rerenderHistory(turns){ log.innerHTML = ''; for (var i = 0; i < turns.length; i++) { appendBubble(turns[i].role, turns[i].content); } } var log = document.getElementById('chatLog'); var form = document.getElementById('chatForm'); var input = document.getElementById('chatInput'); var sendBtn= document.getElementById('chatSend'); if (!log || !form || !input) return; function escHtml(s){ return String(s == null ? '' : s).replace(/[&<>]/g, function(c){ return c==='&'?'&':c==='<'?'<':'>'; }); } function appendBubble(role, content, opts){ opts = opts || {}; var div = document.createElement('div'); div.className = 'chat-msg ' + role + (opts.thinking ? ' thinking' : ''); div.textContent = content; log.appendChild(div); // Scroll the chat panel into view if the user just sent a message if (role === 'user') { try { div.scrollIntoView({ behavior:'smooth', block:'nearest' }); } catch(_){} } return div; } function buildContext(){ var p = __property || window.__wiseProperty || null; var a = __analysis || window.__wiseAnalysis || null; if (!p) return {}; var ctx = { property: { address: p.addressLine || p.addressFull || null, city: p.city || null, state: p.state || null, zip: p.zip || null, county: p.countyLabel || p.county || null, beds: p.beds, baths: p.baths, sqft: p.sqft, yearBuilt: p.yearBuilt, lotSize: p.lotAcres || p.lotSqft || null, assessedValue: p.assessed, avm: p.avm, annualTaxes: p.annualTax, ownerName: p.owner || null, apn: p.apn || null } }; if (a) { ctx.analysis = { fairMarketValue: a.fairMarketValue || a.fairMarket || null, reductionPct: a.reductionPct || null, annualSavings: a.annualSavings || a.annualTaxSavings || null, appealOpportunity: a.appealOpportunity || a.opportunity || null, confidence: a.confidence || null, basis: a.basis || null }; } if (window.__wiseFiling) { ctx.filing = window.__wiseFiling; } else { // Live AI filing instructions haven't loaded (or failed). Fall back // to the offline WPT_STATES directory so the chat is still grounded // in real per-state deadlines instead of the model's open-ended // memory. This is the timeline-grounding fallback called for in the // task spec. var stCode = (p && p.state ? String(p.state).toUpperCase() : ''); var st = (typeof WPT_STATES !== 'undefined' && WPT_STATES[stCode]) ? WPT_STATES[stCode] : null; if (st) { ctx.filing = { source: 'WPT_STATES_FALLBACK', state: stCode, county: p && p.countyLabel ? p.countyLabel : (p && p.county ? p.county : ''), deadline: st.deadline || '', reviewingBody: st.body || '', filingFee: st.filingFee || '', daysFromNotice: typeof st.protestDays === 'number' && st.protestDays > 0 ? st.protestDays : null, proTips: st.tip ? [st.tip] : [], evidenceRequired: st.evidence ? [st.evidence] : [] }; } } if (window.__wiseCad) { ctx.cad = window.__wiseCad; } else { // Same fallback for CAD links — use the offline directory builder // so the assistant can still cite the assessor's official URL. var stCode2 = (p && p.state ? String(p.state).toUpperCase() : ''); var county2 = p && (p.countyLabel || p.county); if (stCode2 && county2 && typeof buildCadLinks === 'function') { try { // buildCadLinks returns [{label, sub, url}, …] — first entry is // the CAD home, second is parcel search, third is the protest // portal. Map them to the same shape the live AI endpoint uses // so the chat prompt formatter doesn't have to special-case. var links = buildCadLinks(stCode2, county2) || []; ctx.cad = { source: 'CAD_BUILDER_FALLBACK', state: stCode2, county: county2, assessorHome: links[0] && links[0].url || null, parcelSearch: links[1] && links[1].url || null, protestPortal: links[2] && links[2].url || null }; } catch(_){} } } return ctx; } // Restore prior conversation. Start from localStorage so the panel // paints instantly on slow networks, then ask the server (using the // session token) for the canonical history and re-render if it // differs. This is the cross-device-resume path: a user who clears // their browser, opens the app on a phone, etc., still gets their // last ~30 turns back. var history = loadHistory(); history.forEach(function(t){ appendBubble(t.role, t.content); }); function hydrateFromServer(){ var token = getSessionToken(); if (!token) return; // unauthenticated visitor — keep local-only mode fetch('/api/property-chat', { credentials: 'same-origin' }) .then(function(r){ if (r.status === 404 || r.status === 401) { // 404: no record yet. 401: session expired between page load // and this fetch. Either way, stay in local-only mode. return null; } if (!r.ok) throw new Error('http_' + r.status); return r.json(); }) .then(function(j){ if (!j) return; var serverTurns = normalizeServerHistory(j.history || []); // Prefer the server copy when it differs from the local cache // (covers the cross-device case and the server-side trim case). // We compare by length + last-turn content as a cheap proxy for // "does the server know something we don't?" var localKey = JSON.stringify(history.map(function(t){return [t.role,t.content];})); var serverKey = JSON.stringify(serverTurns.map(function(t){return [t.role,t.content];})); if (localKey !== serverKey) { history = serverTurns; rerenderHistory(history); saveHistory(history); // refresh the offline mirror } }) .catch(function(){ // Server unreachable / offline — quietly fall back to the // localStorage transcript we already painted. Nothing to do. }); } hydrateFromServer(); function send(messageText){ var msg = String(messageText || '').trim(); if (!msg) return; if (sendBtn.disabled) return; appendBubble('user', msg); history.push({ role: 'user', content: msg }); saveHistory(history); input.value = ''; input.style.height = 'auto'; sendBtn.disabled = true; sendBtn.textContent = 'Thinking…'; var thinking = appendBubble('assistant', 'Wise is thinking…', { thinking: true }); // /api/ai/chat is open (no auth required) and reads conversation // context from the body. We always send recent turns from local // memory; the server-side history mirror lives on /api/property-chat // and is hydrated separately on page load. var body = { message: msg, context: buildContext(), history: history.slice(0, -1).slice(-MAX_TURNS) }; fetch('/api/ai/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(body) }) .then(function(r){ if (!r.ok) throw new Error('http_' + r.status); return r.json(); }) .then(function(j){ var reply = (j && j.reply) ? String(j.reply) : 'Sorry, I couldn\'t generate a response.'; thinking.classList.remove('thinking'); thinking.textContent = reply; history.push({ role: 'assistant', content: reply }); // If the server persisted these turns, take its trimmed copy as // canonical (it may have evicted older turns to stay under the // 30-turn / 32-KB budget). Otherwise just keep the local copy. if (j && j.persisted && Array.isArray(j.history)) { history = normalizeServerHistory(j.history); } saveHistory(history); }) .catch(function(err){ thinking.classList.remove('thinking'); thinking.textContent = 'I had trouble reaching the assistant just now. Please try again in a moment.'; }) .finally(function(){ sendBtn.disabled = false; sendBtn.textContent = 'Ask Wise →'; input.focus(); }); } // Auto-grow textarea input.addEventListener('input', function(){ input.style.height = 'auto'; input.style.height = Math.min(160, input.scrollHeight) + 'px'; }); // Submit on Enter (Shift+Enter for newline) input.addEventListener('keydown', function(e){ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(input.value); } }); form.addEventListener('submit', function(e){ e.preventDefault(); send(input.value); }); // Seed prompt buttons document.querySelectorAll('.chat-seed').forEach(function(btn){ btn.addEventListener('click', function(){ var seed = btn.getAttribute('data-seed') || btn.textContent; send(seed); }); }); // "Clear chat" — wipe the on-screen transcript, the local mirror, // and the server-side history so the next turn starts fresh. We // confirm because this is irreversible (the history is gone for // good once the server clears it). var clearBtn = document.getElementById('chatClearBtn'); if (clearBtn) { clearBtn.addEventListener('click', function(){ if (history.length === 0) { // Nothing to clear locally — still hit the server in case // another device left turns behind, but skip the confirm. } else if (!window.confirm('Clear this conversation? Wise will start fresh and the previous messages will be gone.')) { return; } var prevLabel = clearBtn.textContent; clearBtn.disabled = true; clearBtn.textContent = 'Clearing…'; // Wipe the local transcript + cache up front so the UI feels // instant. If the server call fails we surface a small notice // but keep the local state cleared (the local mirror is just a // cache; the server is canonical and we'll re-hydrate next load). history = []; rerenderHistory(history); saveHistory(history); var token = getSessionToken(); var done = function(){ clearBtn.disabled = false; clearBtn.textContent = prevLabel; }; if (!token) { // No server-side identity yet — local clear is all there is. done(); return; } fetch('/api/property-chat', { method: 'DELETE', credentials: 'same-origin' }) .then(function(r){ // 404 is fine — the token has no server record yet, so there's // nothing to clear server-side and the local clear suffices. if (!r.ok && r.status !== 404) throw new Error('http_' + r.status); }) .catch(function(){ appendBubble('assistant', 'I cleared the conversation locally, but couldn\'t reach the server to clear the saved copy. It may reappear next time you open this page.'); }) .finally(done); }); } })();