diff --git a/package.json b/package.json index 49f4da7..eab943a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/readm3", - "version": "0.24.4", + "version": "0.25.0", "description": "Markdown editing and sharing for people and teams. Web/PWA, CLI, API and MCP with version history, view/edit links, and a terminal reader.", "type": "module", "license": "MIT", diff --git a/server/api.ts b/server/api.ts index 56cfb2d..fcf1113 100644 --- a/server/api.ts +++ b/server/api.ts @@ -24,6 +24,8 @@ const json = ( ) => Response.json(body, { status, headers: { ...headers, ...extra } }); /** A file sent for a summary: up to 20 MB as base64, with room for the JSON around it. */ const MAX_SUMMARY_BODY = 28 * 1024 * 1024; +/** Ask AI: 400 KB of file text plus paths, the question and the turns before it, JSON-escaped. */ +const MAX_ASK_BODY = 2 * 1024 * 1024; async function body(request: Request, max = MAX_BODY): Promise> { if (!request.headers.get("content-type")?.startsWith("application/json")) throw new HttpError(415, "Use application/json."); @@ -350,6 +352,10 @@ export function createApi( limit(`summary:${user.id}`, 30); return json(await money.billing.summary(user, await body(request))); } + if (money && path === "ask" && request.method === "POST") { + limit(`ask:${user.id}`, 30); + return json(await money.billing.ask(user, await body(request, MAX_ASK_BODY))); + } if (money && path === "summaries/document" && request.method === "POST") { limit(`summary:${user.id}`, 30); return json(await money.billing.documentSummary(user, await body(request, MAX_SUMMARY_BODY))); diff --git a/server/billing.ts b/server/billing.ts index f9891d8..2d5477c 100644 --- a/server/billing.ts +++ b/server/billing.ts @@ -53,6 +53,13 @@ export const DEFAULT_MODEL = "claude-opus-5-5"; export const DEFAULT_OPENAI_MODEL = "gpt-5.2"; /** Whole-document summaries each account gets free per week (Monday 00:00 UTC to the next). */ export const FREE_DOCUMENTS_PER_WEEK = 10; +/** Ask AI questions each account gets free per UTC day. */ +export const FREE_QUESTIONS_PER_DAY = 10; +/** Text of the files one question may carry, and the earlier turns it may repeat. */ +export const MAX_ASK_CHARS = 400 * 1024; +export const MAX_ASK_FILES = 500; +export const MAX_QUESTION_CHARS = 4_000; +export const MAX_ASK_TURNS = 10; /** Longest text sent per side, and the most of the diff the model is shown. */ export const MAX_SOURCE = 400 * 1024; /** The largest PDF or office file a summary reads, and the largest image (the models' own per-image cap). */ @@ -178,6 +185,19 @@ export const DOCUMENT_SYSTEM = `You summarize a document for the person reading Write a two or three sentence abstract of what the document is and says, then up to five short bullet points with its key points, decisions, numbers or steps. Be concrete and stay with what the document says; do not add advice of your own. Plain text, no heading, no preamble.`; +export const ASK_SYSTEM = `You answer questions about the person's own files, which are given to you below with their paths. +Answer from what the files say; quote or name the file (by path) a fact comes from. If the files do not answer the question, say so plainly instead of guessing. +Be direct and concise. Plain text; short lists are fine.`; + +/** The prompt for one question: every file under its path, then the conversation so far, then the question. */ +export function askPrompt(files: { path: string; text: string }[], history: { question: string; answer: string }[], question: string, skipped = 0): string { + const parts = files.map((file) => `=== ${file.path} ===\n${file.text}\n`); + if (skipped) parts.push(`(${skipped.toLocaleString("en-US")} more file(s) were left out: too large, or not text.)\n`); + for (const turn of history) parts.push(`Earlier question: ${turn.question}\nYour answer: ${turn.answer}\n`); + parts.push(`Question: ${question}`); + return parts.join("\n"); +} + /** * The prompt for a whole document: its name, and its text cut to the budget with a note * saying so. `note` says where the text came from when it was read out of a file. @@ -227,6 +247,72 @@ export class Billing { store.db.exec(BILLING_SCHEMA); } + /** Ask AI questions free per UTC day (READM3_FREE_QUESTIONS_PER_DAY). */ + freeQuestionsPerDay = FREE_QUESTIONS_PER_DAY; + + /** Today's free Ask AI questions: how many are left and when the count starts again (00:00 UTC). */ + freeQuestions(userId: string, at = new Date()) { + const start = new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate())); + const used = this.store.get<{ n: number }>( + "SELECT COUNT(*) AS n FROM billing_ledger WHERE userId=? AND ref LIKE 'free-question:%' AND createdAt>=?", userId, start.toISOString(), + )?.n ?? 0; + const resets = new Date(start); + resets.setUTCDate(resets.getUTCDate() + 1); + return { perDay: this.freeQuestionsPerDay, left: Math.max(0, this.freeQuestionsPerDay - used), resetsAt: resets.toISOString() }; + } + + /** + * Ask AI: a question about the person's files (text they send: one file, a folder, or + * the workspace), with the conversation so far. Free while today's allowance lasts, + * then from the balance at the arm's price; admins are never charged. + */ + async ask(user: User, args: Record) { + if (!this.summarize) throw new HttpError(503, "Ask AI is not configured on this server."); + const question = typeof args.question === "string" ? args.question.trim() : ""; + if (!question) throw new HttpError(400, "Ask a question."); + if (question.length > MAX_QUESTION_CHARS) throw new HttpError(413, `A question may be up to ${MAX_QUESTION_CHARS.toLocaleString("en-US")} characters.`); + if (!Array.isArray(args.files) || !args.files.length) throw new HttpError(400, "files must list the files to ask about."); + if (args.files.length > MAX_ASK_FILES) throw new HttpError(413, `Ask about up to ${MAX_ASK_FILES} files at once.`); + let total = 0; + const files = args.files.map((file) => { + const { path, text } = (file ?? {}) as Record; + if (typeof path !== "string" || !path.trim() || typeof text !== "string") throw new HttpError(400, "Each file needs a path and its text."); + total += text.length; + return { path: path.trim().slice(0, 1024), text }; + }); + if (total > MAX_ASK_CHARS) throw new HttpError(413, "The files may hold up to 400 KB of text for one question; ask about a smaller folder."); + const history = (Array.isArray(args.history) ? args.history : []).slice(-MAX_ASK_TURNS).map((turn) => { + const { question: q, answer: a } = (turn ?? {}) as Record; + return { question: typeof q === "string" ? q.slice(0, MAX_QUESTION_CHARS) : "", answer: typeof a === "string" ? a.slice(0, 16_000) : "" }; + }).filter((turn) => turn.question && turn.answer); + const skipped = Number.isSafeInteger(args.skipped) && Number(args.skipped) > 0 ? Number(args.skipped) : 0; + const text = askPrompt(files, history, question, skipped); + + const arm = this.arm(user); + const exempt = this.exempt(user.id); + const free = !exempt && this.freeQuestions(user.id).left > 0; + const estimate = this.estimate(arm, text, ASK_SYSTEM); + if (!exempt && !free && this.balance(user.id) < estimate) { + throw new HttpError(402, `Today's ${this.freeQuestionsPerDay} free questions are used. Not enough balance for another (about $${(estimate / 1e6).toFixed(4)}). Top up to continue, or come back tomorrow.`); + } + if (this.running.has(user.id)) throw new HttpError(429, "An answer is already being written for this account."); + this.running.add(user.id); + let result: Awaited>; + try { result = await this.summarize(ASK_SYSTEM, text); } + catch { throw new HttpError(502, "The answer could not be written. You were not charged; try again."); } + finally { this.running.delete(user.id); } + if (result.refused || !result.text) throw new HttpError(422, "The model declined to answer this. You were not charged."); + const model = result.model ?? this.model; + const cost = costMicros(model, result.usage); + const charged = free || exempt ? 0 : priceMicros(cost, arm); + this.store.run( + "INSERT INTO billing_ledger (id,userId,kind,amountMicros,ref,arm,detail,createdAt) VALUES (?,?,?,?,?,?,?,?)", + id(), user.id, "charge", -charged, `${free ? "free-question" : "question"}:${id()}`, free || exempt ? null : arm, + JSON.stringify({ kind: "question", files: files.length, model, costMicros: cost, input: result.usage.input_tokens, output: result.usage.output_tokens }), now(), + ); + return { answer: result.text, model, chargedMicros: charged, free, exempt, freeQuestions: this.freeQuestions(user.id), balanceMicros: this.balance(user.id) }; + } + /** This week's free document summaries: how many are left and when the count starts again. */ free(userId: string, at = new Date()) { const start = weekStart(at); @@ -285,6 +371,7 @@ export class Billing { enabled: !!this.summarize, listen: !!this.speak, freeSummaries: this.free(user.id), + freeQuestions: this.freeQuestions(user.id), ledger: this.store.all<{ kind: string; amountMicros: number; detail: string; createdAt: string }>( "SELECT kind,amountMicros,detail,createdAt FROM billing_ledger WHERE userId=? ORDER BY createdAt DESC LIMIT 20", user.id, ), diff --git a/server/test/billing.test.ts b/server/test/billing.test.ts index 985a57d..f2d6464 100644 --- a/server/test/billing.test.ts +++ b/server/test/billing.test.ts @@ -402,6 +402,32 @@ describe("top-ups", () => { }); }); +describe("ask", () => { + test("ten free questions a day, then the balance; the files and the earlier turns reach the model", async () => { + const prompts: string[] = []; + const f = await fixture(async (_system, text) => { prompts.push(text); return { text: "It is 443.", usage: { input_tokens: 1000, output_tokens: 100 }, refused: false }; }); + f.billing.freeQuestionsPerDay = 2; + const args = { question: "Which port?", files: [{ path: "app/config.json", text: '{"port":443}' }], history: [{ question: "What is this?", answer: "A config." }] }; + for (let i = 0; i < 2; i++) { + const done = await f.call("ask", "POST", args, f.auth); + expect(done.status).toBe(200); + expect(done.data).toMatchObject({ answer: "It is 443.", free: true, chargedMicros: 0 }); + } + expect(prompts[0]).toContain("=== app/config.json ==="); + expect(prompts[0]).toContain("Earlier question: What is this?"); + expect(prompts[0]).toContain("Question: Which port?"); + const refused = await f.call("ask", "POST", args, f.auth); + expect(refused.status).toBe(402); + expect(refused.data.error).toContain("free questions are used"); + f.billing.credit(f.user.id, 1_000_000, "test:1", "test"); + const paid = await f.call("ask", "POST", args, f.auth); + expect(paid.data.free).toBe(false); + expect(paid.data.chargedMicros).toBeGreaterThan(0); + expect((await f.call("ask", "POST", { ...args, files: [] }, f.auth)).status).toBe(400); + expect((await f.call("ask", "POST", { ...args, files: [{ path: "big.txt", text: "x".repeat(401 * 1024) }] }, f.auth)).status).toBe(413); + }); +}); + describe("admins", () => { test("an admin is never charged and never refused for an empty balance", async () => { const f = await fixture(); diff --git a/site/ask-panel.ts b/site/ask-panel.ts new file mode 100644 index 0000000..e191ee7 --- /dev/null +++ b/site/ask-panel.ts @@ -0,0 +1,138 @@ +import { ApiError, request } from "./cloud.ts"; +import { dollars, el } from "./history-panel.ts"; +import { binaryType } from "../src/code.ts"; +import type { Document } from "./workspace.ts"; + +/** + * Ask AI (readm3.com/talk): a conversation with the model about your own files, the open + * one, a folder it is in, or the whole workspace. Only text goes (a PDF, an image or a + * file stored apart is left out and counted), up to 400 KB of it per question. Signed in, + * the first 10 questions each day are free (server/billing.ts FREE_QUESTIONS_PER_DAY), + * then each is paid from the prepaid balance; admins are never charged. + */ +interface FreeQuestions { perDay: number; left: number; resetsAt: string; } +interface Answered { answer: string; chargedMicros: number; free: boolean; exempt?: boolean; freeQuestions: FreeQuestions; balanceMicros: number; } +type Turn = { question: string; answer: string }; + +const MAX_TEXT = 400 * 1024; +const MAX_FILES = 500; + +/** The folders holding `path`, nearest first: "a/b/c.md" is in "a/b", then "a". */ +function folders(path: string): string[] { + const parts = path.split("/").slice(0, -1); + return parts.map((_, i) => parts.slice(0, parts.length - i).join("/")); +} + +/** The text files under a scope ("" = everything), as much as one question carries. */ +export function gather(documents: Document[], scope: { file?: string; folder?: string }) { + const chosen = documents.filter((doc) => scope.file !== undefined ? doc.path === scope.file + : !scope.folder || doc.path.startsWith(`${scope.folder}/`)); + const files: { path: string; text: string }[] = []; + let size = 0; + let skipped = 0; + for (const doc of chosen) { + if (doc.blob || binaryType(doc.path) || !doc.source.trim() || files.length >= MAX_FILES || size + doc.source.length > MAX_TEXT) { skipped++; continue; } + files.push({ path: doc.path, text: doc.source }); + size += doc.source.length; + } + return { files, skipped }; +} + +const freeLine = (free: FreeQuestions) => free.left > 0 + ? `${free.left} of ${free.perDay} free questions left today` + : `Today's ${free.perDay} free questions are used; more at midnight UTC`; + +export async function showAsk(documents: () => Document[], active: string, topUp: () => void): Promise { + const box = document.createElement("dialog"); + box.className = "history-dialog summary-dialog ask-dialog"; + box.setAttribute("aria-labelledby", "ask-title"); + box.innerHTML = `

Ask AI about your files

+ +
+
+`; + const log = box.querySelector(".ask-log")!; + const account = box.querySelector(".history-account")!; + const select = box.querySelector("#ask-scope")!; + const form = box.querySelector(".ask-form")!; + const input = box.querySelector("#ask-question")!; + const submit = form.querySelector("button")!; + box.querySelector(".dialog-header button")!.onclick = () => box.close(); + box.addEventListener("click", (event) => { if (event.target === box) box.close(); }); + box.addEventListener("close", () => box.remove()); + document.body.append(box); + box.showModal(); + + const scopes: { label: string; scope: { file?: string; folder?: string } }[] = [ + { label: `This file · ${active.split("/").at(-1)}`, scope: { file: active } }, + ...folders(active).map((folder) => ({ label: `Folder · ${folder}/`, scope: { folder } })), + { label: "Whole workspace", scope: {} }, + ]; + for (const [i, { label }] of scopes.entries()) select.append(new Option(label, String(i))); + // Each scope keeps its own conversation; changing scope starts a new one. + const turns = new Map(); + const say = (message: string) => log.replaceChildren(el("p", "history-empty", message)); + const describe = () => { + const { files, skipped } = gather(documents(), scopes[Number(select.value)]!.scope); + const past = turns.get(Number(select.value)) ?? []; + log.replaceChildren(); + for (const turn of past) log.append(el("p", "ask-question", turn.question), el("p", "summary-text", turn.answer)); + if (!past.length) say(files.length + ? `${files.length.toLocaleString()} file${files.length === 1 ? "" : "s"} will be read${skipped ? ` (${skipped.toLocaleString()} left out: not text, too large, or past 400 KB)` : ""}. Ask anything about ${files.length === 1 ? "it" : "them"}.` + : "There is no text to ask about here. Pick a folder or the whole workspace."); + }; + select.onchange = () => { describe(); input.focus(); }; + input.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); form.requestSubmit(); } }); + + say("Loading…"); + let billing: { enabled?: boolean; freeQuestions?: FreeQuestions; exempt?: boolean; balanceMicros: number } | null = null; + try { billing = await request("billing"); } catch { billing = null; } + if (!billing) { + const link = el("a", undefined, "Sign in (free)"); + link.href = `/account?next=${encodeURIComponent("/talk")}`; + const line = el("p"); + line.append(link, " to ask AI about your files: 10 questions a day free, then pay as you go."); + log.replaceChildren(line); + form.hidden = true; + return; + } + if (!billing.enabled) { say("Ask AI is not available on this server yet."); form.hidden = true; return; } + account.textContent = billing.exempt ? "Admin, no charges" : billing.freeQuestions ? freeLine(billing.freeQuestions) : ""; + describe(); + input.focus(); + + form.onsubmit = async (event) => { + event.preventDefault(); + const question = input.value.trim(); + if (!question || submit.disabled) return; + const index = Number(select.value); + const { files, skipped } = gather(documents(), scopes[index]!.scope); + if (!files.length) { say("There is no text to ask about here. Pick a folder or the whole workspace."); return; } + const past = turns.get(index) ?? []; + if (!past.length) log.replaceChildren(); + const pending = el("p", "history-empty", "Thinking…"); + log.append(el("p", "ask-question", question), pending); + input.value = ""; + submit.disabled = true; + try { + const result = await request("ask", "POST", { question, files, skipped, history: past }); + pending.replaceWith(el("p", "summary-text", result.answer)); + turns.set(index, [...past, { question, answer: result.answer }]); + const cost = result.exempt ? "Admin, no charge" : result.free ? "Free" : dollars(result.chargedMicros); + account.textContent = result.exempt ? cost : `${cost} · ${freeLine(result.freeQuestions)} · balance ${dollars(result.balanceMicros)}`; + } catch (error) { + pending.textContent = error instanceof Error ? error.message : "The question failed."; + input.value = question; + if (error instanceof ApiError && error.status === 402) { + const top = el("button", "ghost", "Top up"); + top.type = "button"; + top.onclick = () => { box.close(); topUp(); }; + account.replaceChildren(top); + } + } finally { + submit.disabled = false; + log.scrollTop = log.scrollHeight; + input.focus(); + } + }; +} diff --git a/site/assets/viewer.css b/site/assets/viewer.css index 0b068b0..925ca0e 100644 --- a/site/assets/viewer.css +++ b/site/assets/viewer.css @@ -232,6 +232,13 @@ html, body.reader-app { height: 100%; overflow: hidden; } .reader-app .summary-dialog { width: min(640px, calc(100vw - 32px)); } .reader-app .summary-dialog .summary-body, .reader-app .summary-dialog > p { padding: 0 18px; } .reader-app .summary-text { white-space: pre-wrap; line-height: 1.55; margin: 4px 0 8px; overflow-wrap: anywhere; } +.reader-app .ask-dialog .ask-log { max-height: min(55vh, 520px); overflow-y: auto; } +.reader-app .ask-dialog .ask-scope { display: flex; gap: 8px; align-items: center; padding: 0 18px 8px; min-width: 0; } +.reader-app .ask-dialog .ask-scope select { min-width: 0; flex: 1; } +.reader-app .ask-question { margin: 12px 0 2px; font-weight: 600; white-space: pre-wrap; overflow-wrap: anywhere; } +.reader-app .ask-form { display: flex; gap: 8px; align-items: flex-end; padding: 8px 18px; } +.reader-app .ask-form textarea { flex: 1; min-width: 0; resize: vertical; font: inherit; } +.reader-app .ask-form[hidden] { display: none; } .reader-app .summary-listen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 8px; } .reader-app .summary-listen audio { width: 100%; max-width: 420px; height: 36px; } .reader-app .summary-listen small { color: var(--muted); } diff --git a/site/assets/viewer.html b/site/assets/viewer.html index dda3fc2..c86ef9f 100644 --- a/site/assets/viewer.html +++ b/site/assets/viewer.html @@ -59,6 +59,7 @@ + diff --git a/site/build.ts b/site/build.ts index b968d66..7adfa9c 100644 --- a/site/build.ts +++ b/site/build.ts @@ -194,6 +194,7 @@ function shell(options: { title: string; description: string; path: string; main