diff --git a/src/js/chat-widget.el b/src/js/chat-widget.el index a9c0250..5e408e8 100644 --- a/src/js/chat-widget.el +++ b/src/js/chat-widget.el @@ -276,6 +276,41 @@ fn main() -> Void { }); var d = await r.json(); if (thinking) thinking.remove(); + + // Server-side rate limit — show a live countdown to reset + if (d.rate_limited && d.reset_at) { + var _showRateTimer = function() { + var now = Math.floor(Date.now() / 1000); + var secsLeft = Math.max(0, d.reset_at - now); + var hh = Math.floor(secsLeft / 3600); + var mm = Math.floor((secsLeft % 3600) / 60); + var ss = secsLeft % 60; + var pad = function(n) { return n < 10 ? '0' + n : '' + n; }; + var ts = hh > 0 ? (hh + ':' + pad(mm) + ':' + pad(ss)) : (pad(mm) + ':' + pad(ss)); + return 'You\'ve had 10 conversations today. Come back in ' + ts + '.'; + }; + addMsg('ai', _showRateTimer()); + // Update the last ai message with a live ticker + var _timerInterval = setInterval(function() { + var thMsgsInner = document.getElementById('neuron-demo-msgs'); + if (!thMsgsInner) { clearInterval(_timerInterval); return; } + var aiMsgs = thMsgsInner.querySelectorAll('.neuron-msg-ai'); + var lastAi = aiMsgs[aiMsgs.length - 1]; + if (lastAi) { lastAi.textContent = _showRateTimer(); } + if (Math.floor(Date.now() / 1000) >= d.reset_at) { + clearInterval(_timerInterval); + if (lastAi) { lastAi.textContent = 'You\'re all set — conversations reset. Say hello!'; } + if (input) { input.disabled = false; input.placeholder = 'Ask me anything...'; } + if (btn) { btn.disabled = false; } + } + }, 1000); + if (input) { input.disabled = true; input.placeholder = 'Come back tomorrow...'; } + if (btn) { btn.disabled = true; } + if (btn) { btn.disabled = false; } + if (input) { input.focus(); } + return; + } + _um(session, d.sn, d.se); var reply = d.response || d.reply || d.message || ''; var isError = !reply || reply === 'Stepped out for a moment. Try again.'; diff --git a/src/main.el b/src/main.el index 3c064f3..8ca4a48 100644 --- a/src/main.el +++ b/src/main.el @@ -1086,16 +1086,36 @@ fn handle_request(method: String, path: String, body: String) -> String { if str_eq(msg, "") { return "{\"error\":\"message required\"}" } - // Rate limit: 25 requests per uid per hour (stored in process state) + // Rate limit: 10 chats per uid per day (UTC day, keyed by uid). + // State key: "__rl_" → "|" + // day_number = unix_timestamp / 86400 (integer UTC day) + // Returns rate_limited JSON with reset_at (next midnight UTC) so + // the frontend can show a real countdown. let rate_uid: String = json_get(body, "uid") if !str_eq(rate_uid, "") { - let rate_key: String = "__rate__" + rate_uid - let rate_val: String = state_get(rate_key) - let rate_count: Int = if str_eq(rate_val, "") { 0 } else { str_to_int(rate_val) } - if rate_count >= 25 { - return "{\"response\":\"You've hit the rate limit. Come back in an hour.\"}" + let now_ts: Int = unix_timestamp() + let today_day: Int = now_ts / 86400 + let next_reset: Int = (today_day + 1) * 86400 + let rl_key: String = "__rl_" + rate_uid + let rl_val: String = state_get(rl_key) + let rl_count: Int = 0 + let rl_day: Int = 0 + if !str_eq(rl_val, "") { + // format: "count|day" + let parts: [String] = str_split(rl_val, "|") + if native_list_len(parts) >= 2 { + let rl_count = str_to_int(native_list_get(parts, 0)) + let rl_day = str_to_int(native_list_get(parts, 1)) + } } - state_set(rate_key, int_to_str(rate_count + 1)) + // Reset count if it's a new day + if rl_day != today_day { + let rl_count = 0 + } + if rl_count >= 10 { + return "{\"rate_limited\":true,\"reset_at\":" + int_to_str(next_reset) + "}" + } + state_set(rl_key, int_to_str(rl_count + 1) + "|" + int_to_str(today_day)) } // Turnstile: verify on first message only (tokens are single-use). // Per-message verification breaks chat flow. Forms get full verification.