Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions ai-cooldown.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use strict';
// AI analysis runs once per 24 hours per user, enforced at the API. A run is the burst of
// analysis requests fired together; it opens on the first successful response and closes
// fifteen minutes later; the next run is 24 hours after it opened. Per-request features
// (Ask Book, rewrite) are untouched, and the dev tier is exempt.
const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs');
const rl=require('./api/_ratelimit');

test('the analysis window: open on success, a grace period for the burst, then closed until 24 hours',async()=>{
const t0=Date.parse('2026-09-22T10:00:00Z');
const uid='window-'+Math.random();
assert.deepEqual(await rl.analysisRun(uid,t0),{allowed:true,startedAt:null,nextAt:null});
await rl.openAnalysisRun(uid,t0);
const inBurst=await rl.analysisRun(uid,t0+10*60*1000);
assert.equal(inBurst.allowed,true,'the rest of the burst goes through');
assert.equal(inBurst.startedAt,t0);
const later=await rl.analysisRun(uid,t0+20*60*1000);
assert.equal(later.allowed,false);
assert.equal(later.nextAt,t0+rl.ANALYSIS_PERIOD_MS);
await rl.openAnalysisRun(uid,t0+20*60*1000);
assert.equal((await rl.analysisRun(uid,t0+21*60*1000)).startedAt,t0,'a second open inside the period does not move the clock');
assert.equal((await rl.analysisRun(uid,t0+rl.ANALYSIS_PERIOD_MS+1)).allowed,true,'the day is over');
});

function proxy({feature,tier='premium',run,env={OPENAI_API_KEY:'k'},provider=200}={}){
const calls={analysisRun:0,open:0,provider:0};
const db={collection:name=>({doc:uid=>({path:name+'/'+uid})}),getAll:async()=>[{exists:true,data:()=>({tier})},{exists:false}]};
const context={module:{exports:{}},process:{env},console,Buffer,Set,Date,Number,Math,JSON,
require:id=>{
if(id==='https'){
return {request(options,onResponse){
calls.provider++;
const listeners={};
const res={statusCode:provider,on(ev,fn){listeners[ev]=fn;}};
return {on(){},setTimeout(){},write(){},end(){setImmediate(()=>{onResponse(res);listeners.data('{"choices":[{"message":{"content":"{}"}}]}');listeners.end();});}};
}};
}
if(id==='./_auth')return {verifyToken:async()=>({user:{uid:'author'}}),getAdmin:()=>({firestore:()=>db})};
if(id==='./_ratelimit')return {checkAndIncrement:async()=>1,analysisRun:async()=>{calls.analysisRun++;return run||{allowed:true,startedAt:null,nextAt:null};},openAnalysisRun:async()=>{calls.open++;}};
return require(id);
}};
vm.runInNewContext(fs.readFileSync(require.resolve('./api/claude'),'utf8'),context);
const handler=context.module.exports;
return {calls,async call(){
const res={statusCode:200,headers:{},status(c){this.statusCode=c;return this;},json(b){this.body=b;return this;},setHeader(k,v){this.headers[k]=v;}};
await handler({method:'POST',headers:{'x-model':'openai-fast','x-feature':feature},body:{messages:[{role:'user',content:'Synthetic'}]}},res);
return res;
}};
}

test('a second analysis run inside 24 hours is refused with the next time, before the daily count',async()=>{
const nextAt=Date.now()+3600000;
const p=proxy({feature:'deepCritique',run:{allowed:false,startedAt:nextAt-86400000,nextAt}});
const res=await p.call();
assert.equal(res.statusCode,429);
assert.equal(res.body.error.code,'ANALYSIS_COOLDOWN');
assert.equal(res.body.error.nextAt,nextAt);
assert.match(res.body.error.message,/once every 24 hours/);
assert.ok(Number(res.headers['Retry-After'])>0);
assert.equal(p.calls.provider,0,'nothing reached a provider');
});

test('the run opens only after a successful provider response',async()=>{
const ok=proxy({feature:'editingRoadmap'});
await ok.call();
assert.equal(ok.calls.analysisRun,1);
assert.equal(ok.calls.open,1,'a successful analysis opens the window');
const failed=proxy({feature:'editingRoadmap',provider:500});
await failed.call();
assert.equal(failed.calls.open,0,'a failed provider call does not spend the run');
});

test('per-request features and the dev tier never consult the analysis window',async()=>{
const ask=proxy({feature:'ask-manuscript',run:{allowed:false,startedAt:1,nextAt:Date.now()+1000}});
const res=await ask.call();
assert.equal(res.statusCode,200);
assert.equal(ask.calls.analysisRun,0);
const dev=proxy({feature:'deepCritique',run:{allowed:false,startedAt:1,nextAt:Date.now()+1000},env:{OPENAI_API_KEY:'k',ADMIN_UIDS:'author'}});
assert.equal((await dev.call()).statusCode,200);
assert.equal(dev.calls.analysisRun,0);
});
23 changes: 23 additions & 0 deletions ai-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ const AIEngine = {
// Local dev: /api/claude (Express server)
// Both work with the same path
API_ENDPOINT: '/api/claude',
// Whole-manuscript analysis features run once per 24 hours; the server enforces it and this
// browser remembers the next run time it was told, so the author sees the time, not a spent request.
ANALYSIS_FEATURES: new Set(['deepCritique','chapterBreakdown','openingAnalysis','weaknessAnalysis','compTitles','queryLetter','betaReaders','marketReadiness','editingRoadmap','readerSimulation','smartScan','calibrateIssues']),
nextAnalysisRun() {
try { const next = Number(localStorage.getItem('ml_ai_next_run')); if (next && next > Date.now()) return next; if (next) localStorage.removeItem('ml_ai_next_run'); } catch (_) {}
return null;
},
cooldownMessage(nextAt) {
const when = new Date(nextAt);
const sameDay = when.toDateString() === new Date().toDateString();
return 'AI analysis runs once every 24 hours. The next run is available ' + (sameDay ? 'today at ' + when.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : when.toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })) + '.';
},

// Model routing: cheap OpenAI for most, Claude for premium
_routeModel(feature) {
Expand Down Expand Up @@ -146,6 +158,13 @@ const AIEngine = {
]
});

// AI analysis runs once per 24 hours (server-enforced). When the server has told this browser
// the next run time, say so without spending a request; cached results above still show.
if (AIEngine.ANALYSIS_FEATURES.has(headers['x-feature'])) {
const next = AIEngine.nextAnalysisRun();
if (next) throw new Error(AIEngine.cooldownMessage(next));
}

let response = await fetch(endpoint, { method: 'POST', headers, body: bodyPayload });

if (response.status === 401) {
Expand All @@ -161,6 +180,10 @@ const AIEngine = {
if (!response.ok) {
const err = await response.json().catch(() => ({}));
console.error('[AI API error]', response.status, err, 'User:', currentUser?.email || 'none', 'Had token:', !!headers['authorization']);
if (err.error?.code === 'ANALYSIS_COOLDOWN' && Number.isFinite(Number(err.error.nextAt))) {
try { localStorage.setItem('ml_ai_next_run', String(Number(err.error.nextAt))); } catch (_) {}
throw new Error(AIEngine.cooldownMessage(Number(err.error.nextAt)));
}
if (err.error?.code === 'UNAUTHENTICATED') {
const reason = err.error?.reason || 'unknown';
const diag = err.error?.diagnostic || '';
Expand Down
35 changes: 34 additions & 1 deletion api/_ratelimit.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,37 @@ async function getCount(userId, today) {
return fallbackMap.get(key) || 0;
}

module.exports = { checkAndIncrement, getCount };
// AI analysis runs once per 24 hours per user. A "run" is the burst of analysis requests an
// author fires together (deep critique, roadmap, opening…), so requests are allowed for a
// grace window after the run opens; after that the next run is 24 hours from the opening.
// The run opens only after a provider request succeeds, so a failed call does not spend it.
const ANALYSIS_PERIOD_MS = 24 * 60 * 60 * 1000;
const ANALYSIS_GRACE_MS = 15 * 60 * 1000;
const runFallback = new Map();

async function analysisRun(userId, nowMs = Date.now()) {
const key = 'ai:run:' + userId;
const r = getRedis();
if(!r&&(process.env.VERCEL||process.env.NODE_ENV==='production'))throw new Error('Persistent rate limiter unavailable');
let startedAt = null;
if (r) { const raw = await r.get(key); startedAt = raw == null ? null : Number(raw); }
else {
const entry = runFallback.get(key);
if (entry && entry.expiresAt > nowMs) startedAt = entry.startedAt; else runFallback.delete(key);
}
if (startedAt == null || !Number.isFinite(startedAt)) return { allowed: true, startedAt: null, nextAt: null };
const nextAt = startedAt + ANALYSIS_PERIOD_MS;
if (nowMs >= nextAt) return { allowed: true, startedAt: null, nextAt: null };
return { allowed: nowMs - startedAt <= ANALYSIS_GRACE_MS, startedAt, nextAt };
}

async function openAnalysisRun(userId, nowMs = Date.now()) {
const key = 'ai:run:' + userId;
const r = getRedis();
if(!r&&(process.env.VERCEL||process.env.NODE_ENV==='production'))throw new Error('Persistent rate limiter unavailable');
if (r) { await r.set(key, String(nowMs), { nx: true, ex: Math.ceil(ANALYSIS_PERIOD_MS / 1000) }); return; }
const entry = runFallback.get(key);
if (!entry || entry.expiresAt <= nowMs) runFallback.set(key, { startedAt: nowMs, expiresAt: nowMs + ANALYSIS_PERIOD_MS });
}

module.exports = { checkAndIncrement, getCount, analysisRun, openAnalysisRun, ANALYSIS_PERIOD_MS, ANALYSIS_GRACE_MS };
50 changes: 37 additions & 13 deletions api/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
// Rate limited per user per day
const https = require('https');
const { verifyToken, getAdmin } = require('./_auth');
const { checkAndIncrement } = require('./_ratelimit');
const { checkAndIncrement, analysisRun, openAnalysisRun } = require('./_ratelimit');

const LIMITS = { dev: 9999, beta: 50, premium: 75, starter: 25, free: 0 };
// Whole-manuscript AI analysis runs once per 24 hours (see _ratelimit.analysisRun). Ask Book,
// rewrites and fixes are per-request features and stay on the daily count above.
const ANALYSIS_FEATURES = new Set(['deepcritique','chapterbreakdown','openinganalysis','weaknessanalysis','comptitles','queryletter','betareaders','marketreadiness','editingroadmap','readersimulation','smartscan','calibrateissues']);

async function getUserTier(userId) {
const fb = getAdmin();
Expand Down Expand Up @@ -124,6 +127,23 @@ module.exports = async (req, res) => {
// Count only authenticated, authorized, structurally valid requests.
const needsClaude=requestedModel==='claude'||requestedModel==='claude-premium';
if(!(needsClaude?process.env.CLAUDE_API_KEY:process.env.OPENAI_API_KEY))return res.status(503).json({error:{message:'Requested provider is unavailable'}});
// One AI analysis run per 24 hours. Checked before the daily count so a blocked run is
// not charged against it; the dev tier is exempt so the feature can be exercised.
const isAnalysis = ANALYSIS_FEATURES.has(requestedFeature) && userTier !== 'dev';
let run = null;
if (isAnalysis) {
try { run = await analysisRun(userId, Date.now()); }
catch (_) { return res.status(503).json({ error: { message: 'Usage verification unavailable. Please try again.' } }); }
if (!run.allowed) {
res.setHeader('Retry-After', String(Math.max(1, Math.ceil((run.nextAt - Date.now()) / 1000))));
res.status(429).json({
error: {
message: 'AI analysis runs once every 24 hours. The next run is available at ' + new Date(run.nextAt).toISOString() + '.',
code: 'ANALYSIS_COOLDOWN', startedAt: run.startedAt, nextAt: run.nextAt
}
}); return;
}
}
let used;
try{used=await checkAndIncrement(userId,today);}
catch(_){return res.status(503).json({error:{message:'Usage verification unavailable. Please try again.'}});}
Expand All @@ -136,16 +156,18 @@ module.exports = async (req, res) => {
}); return;
}

if (requestedModel === 'claude' || requestedModel === 'claude-premium') {
return callClaude(sanitized, res);
} else {
return callOpenAI(sanitized, requestedModel, res);
const status = (requestedModel === 'claude' || requestedModel === 'claude-premium')
? await callClaude(sanitized, res)
: await callOpenAI(sanitized, requestedModel, res);
// The run opens on the first successful analysis response, never on a failed one.
if (isAnalysis && run && run.startedAt == null && status >= 200 && status < 300) {
try { await openAnalysisRun(userId, Date.now()); } catch (_) { /* the response has been sent; nothing to add */ }
}
};

function callClaude(body, res) {
const apiKey = process.env.CLAUDE_API_KEY;
if (!apiKey) { res.status(500).json({ error: { message: 'Claude API key not configured' } }); return Promise.resolve(); }
if (!apiKey) { res.status(500).json({ error: { message: 'Claude API key not configured' } }); return Promise.resolve(500); }

const payload = JSON.stringify({
model: 'claude-sonnet-4-20250514',
Expand All @@ -168,12 +190,13 @@ function callClaude(body, res) {
let data = '';
proxyRes.on('data', chunk => data += chunk);
proxyRes.on('end', () => {
let status = proxyRes.statusCode;
try { res.status(proxyRes.statusCode).json(JSON.parse(data)); }
catch (e) { res.status(500).json({ error: { message: 'Invalid response from Claude' } }); }
resolve();
catch (e) { status = 500; res.status(500).json({ error: { message: 'Invalid response from Claude' } }); }
resolve(status);
});
});
proxyReq.on('error', err => { res.status(500).json({ error: { message: err.message } }); resolve(); });
proxyReq.on('error', err => { res.status(500).json({ error: { message: err.message } }); resolve(500); });
proxyReq.setTimeout(30000,()=>proxyReq.destroy(new Error('Provider request timed out')));
proxyReq.write(payload); proxyReq.end();
});
Expand All @@ -183,7 +206,7 @@ function callOpenAI(body, model, res) {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
res.status(503).json({error:{message:'Requested provider is unavailable'}});
return Promise.resolve();
return Promise.resolve(503);
}

// Map our model names to OpenAI models
Expand Down Expand Up @@ -225,6 +248,7 @@ function callOpenAI(body, model, res) {
let data = '';
proxyRes.on('data', chunk => data += chunk);
proxyRes.on('end', () => {
let status = proxyRes.statusCode;
try {
const parsed = JSON.parse(data);
if (parsed.error) {
Expand All @@ -241,11 +265,11 @@ function callOpenAI(body, model, res) {
_provider: 'openai', _model: openaiModel
});
}
} catch (e) { res.status(500).json({ error: { message: 'Invalid response from OpenAI' } }); }
resolve();
} catch (e) { status = 500; res.status(500).json({ error: { message: 'Invalid response from OpenAI' } }); }
resolve(status);
});
});
proxyReq.on('error', err => { res.status(500).json({ error: { message: err.message } }); resolve(); });
proxyReq.on('error', err => { res.status(500).json({ error: { message: err.message } }); resolve(500); });
proxyReq.setTimeout(30000,()=>proxyReq.destroy(new Error('Provider request timed out')));
proxyReq.write(payload); proxyReq.end();
});
Expand Down
4 changes: 2 additions & 2 deletions app.html
Original file line number Diff line number Diff line change
Expand Up @@ -457,13 +457,13 @@ <h2 style="font-family:Lora,serif;color:var(--gold-l);margin-bottom:.3rem">Choos
<script src="chapter-metrics.js?v=1"></script>
<script src="intelligence-pipeline.js?v=2"></script>
<script src="manuscript-retrieval.js?v=2"></script>
<script src="ai-engine.js?v=21"></script>
<script src="ai-engine.js?v=22"></script>
<script src="fixer.js?v=18"></script>
<!-- intelligence dependencies are loaded above AIEngine -->
<script data-intelligence-order="complete"></script>
<script src="storage.js?v=20"></script>
<script src="analysis-runner.js?v=2"></script>
<script src="app.js?v=36"></script>
<script src="app.js?v=37"></script>
<script src="workspace-header.js?v=3"></script>
<script src="mobile-panels.js?v=21"></script>

Expand Down
8 changes: 6 additions & 2 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2498,11 +2498,15 @@ async function runAI(key){
}catch(e){
st.classList.add('hidden');
const msg=e.message||'Unknown error';
const isRateLimit=msg.includes('limit reached')||msg.includes('RATE_LIMITED');
const isCooldown=msg.includes('once every 24 hours');
const isRateLimit=!isCooldown&&(msg.includes('limit reached')||msg.includes('RATE_LIMITED'));
const isAuthErr=msg.startsWith('SERVER_AUTH_ERROR:');
const intro=document.querySelector('.ai-intro');
if(intro){
if(isRateLimit){
if(isCooldown){
intro.innerHTML='<div style="padding:1.5rem;text-align:center"><div style="font-size:2.5rem;margin-bottom:.5rem">&#9203;</div><h4 style="color:var(--gold-l);margin-bottom:.5rem">AI analysis runs once every 24 hours</h4><p style="color:var(--muted);font-size:.85rem;margin-bottom:.4rem">'+esc(msg.replace(/^AI analysis runs once every 24 hours\.\s*/,''))+'</p><p style="color:var(--dim);font-size:.72rem">Your last results stay open. Ask Book, rewrites and fixes are not affected.</p></div>';
intro.classList.remove('hidden');
}else if(isRateLimit){
intro.innerHTML='<div style="padding:1.5rem;text-align:center"><div style="font-size:2.5rem;margin-bottom:.5rem">&#128274;</div><h4 style="color:var(--gold-l);margin-bottom:.5rem">Daily Limit Reached</h4><p style="color:var(--muted);font-size:.85rem;margin-bottom:1rem">Free accounts get 3 AI analyses per day.</p><p style="color:var(--muted);font-size:.78rem">Resets at midnight UTC.</p><button class="btn-gold ai-upgrade-btn" style="width:auto;padding:.5rem 1.5rem;margin-top:1rem">&#9733; Upgrade to Premium — $5/mo</button><p style="color:var(--dim);font-size:.7rem;margin-top:.5rem">50 AI analyses/day + Claude deep critique</p></div>';
intro.querySelector('.ai-upgrade-btn')?.addEventListener('click',()=>$('pricing-modal')?.classList.remove('hidden'));
}else if(isAuthErr){
Expand Down
40 changes: 40 additions & 0 deletions features-init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Features page motion: reveal sections as they arrive, draw the curve once it is seen, let the
// product frame breathe and tilt under the pointer, and let each card's glow follow the mouse.
// Everything here is decorative; the page reads the same with scripting off or reduced motion.
(() => {
'use strict';
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const reveals = [...document.querySelectorAll('.fx-reveal')];
if (reduce || !('IntersectionObserver' in window)) reveals.forEach(el => el.classList.add('is-in'));
else {
const io = new IntersectionObserver(entries => {
entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('is-in'); io.unobserve(entry.target); } });
}, { rootMargin: '0px 0px -10% 0px', threshold: 0.15 });
reveals.forEach((el, i) => { el.style.transitionDelay = (i % 3) * 90 + 'ms'; io.observe(el); });
}
if (reduce) return;

// The frame tilts a few degrees toward the pointer and settles back when it leaves.
const frame = document.getElementById('fx-frame');
const win = frame && frame.querySelector('.fx-window');
if (win && matchMedia('(hover: hover)').matches) {
let raf = 0, rx = 0, ry = 0;
const paint = () => { raf = 0; win.style.transform = 'rotateX(' + rx.toFixed(2) + 'deg) rotateY(' + ry.toFixed(2) + 'deg) scale(1.01)'; };
frame.addEventListener('pointermove', e => {
const r = win.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5, py = (e.clientY - r.top) / r.height - 0.5;
rx = -py * 5; ry = px * 6;
if (!raf) raf = requestAnimationFrame(paint);
});
frame.addEventListener('pointerleave', () => { if (raf) cancelAnimationFrame(raf); raf = 0; win.style.transform = ''; });
}

// Each card's warm glow follows the pointer.
document.querySelectorAll('.fx-card').forEach(card => {
card.addEventListener('pointermove', e => {
const r = card.getBoundingClientRect();
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100).toFixed(1) + '%');
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100).toFixed(1) + '%');
});
});
})();
Loading
Loading