Files
petal/web/src/api/client.ts
T
prosolis e67f77eb05 The pass announces the verdict it just decided
Storing doc_lang on the document row is not enough on its own. The
editor sees that row when the document is opened or saved, and the pass
that decides the verdict runs after a save — so the client was always
one save behind, and read-aloud is reached for precisely when she has
stopped typing and no further save is coming. Heard in a browser: a
Portuguese paragraph read in an American voice, twice, until another
keystroke went in.

/check, /voice and /collocation now answer with X-Petal-Doc-Lang. A
header rather than a wider body: all three answer with a bare array of
the unified pending set and every caller reads it as one, and a verdict
is metadata about the pass rather than another suggestion. It reaches
the app through the same handler shape onUnauthorized already uses.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 00:21:15 -07:00

616 lines
26 KiB
TypeScript

// Thin fetch wrappers for the Petal backend. Everything lives under /api, which
// Vite proxies to the Go server in dev and the binary serves directly in prod.
// A tag's palette key, mapped to a CSS color on the frontend. Mirrors the
// backend TagColor* constants; unknown values render as rose.
export type TagColor = 'rose' | 'mint' | 'peach' | 'lavender' | 'sky' | 'honey'
export interface Tag {
id: string
name: string
color: TagColor
doc_count?: number // present only in the tag-roster listing
}
export interface DocSummary {
id: string
title: string
word_count: number
updated_at: string
tags: Tag[]
}
// One cross-document search hit. `snippet` is plain text with the matched span
// wrapped in the  sentinels (see splitSnippet) for highlighting.
export interface SearchResult {
id: string
title: string
word_count: number
updated_at: string
snippet: string
tags: Tag[]
}
export interface Document {
id: string
user_id: string
title: string
content: string // Tiptap JSON (stringified)
content_text: string // flattened plain text
tone: string // target writing tone; steers LLM advice
word_count: number
created_at: string
updated_at: string
// When true, this document's automatic snapshots are never pruned, so its
// full writing trail survives as authorship evidence (see the passport).
preserve_history: boolean
// Which language this document is written in, as decided server-side by the
// checkpoint pass: '' | 'en' | 'pair' ('' reads as English). Read-only — the
// editor never sends it. It is here so read-aloud can use the right voice on a
// document written in her own language.
doc_lang: DocLang
}
// The document-language verdict, shared by documents and garden cards. 'pair'
// names the writer's own language rather than a language code, so changing her
// pair re-reads her documents instead of stranding a stale name on them.
export type DocLang = '' | 'en' | 'pair'
// Fields the editor sends on auto-save. All optional so a rename can send title
// alone; the editor sends the full set.
export interface DocUpdate {
title?: string
content?: string
content_text?: string
tone?: string
word_count?: number
preserve_history?: boolean
}
// One sense of a word from the offline dictionary.
export interface WordMeaning {
part_of_speech: string
definition: string
example?: string
}
// The offline lookup for one word: a Chinese gloss, a few definition senses, and
// a list of synonyms. Any of these may be empty when the word isn't a headword.
export interface WordInfo {
word: string
gloss: string // translation into the writer's language; '' when absent
phonetic: string // IPA for the English word; '' when absent
definitions: WordMeaning[]
synonyms: string[]
// From DreamDict only; the embedded datasets leave them unknown. `frequency`
// is 0 and `difficulty` is -1 when the dictionary has no score — see
// wordBand, which turns the pair into a band or into nothing at all.
frequency: number
difficulty: number
etymology: string // free-form, already trimmed to a line by the server; '' when absent
// The same token read as a word of the writer's own language, when it is one.
// Absent for a zh-pair writer and for almost every word in a Latin pair — see
// lexicon.Reverse for why Petal asks both directions instead of guessing.
reverse?: WordReverse
}
// A word looked up in the other direction: the writer's language -> English.
export interface WordReverse {
lang: string
gloss: string
definitions?: WordMeaning[]
phonetic?: string
}
// The lightweight Chinese-only gloss behind the inline hover/select tooltip.
export interface Gloss {
word: string
gloss: string
// The English meaning of the token read as a word of her own language.
// Present only on a collision (Portuguese *sale*, French *chat*).
reverse?: string
}
// 'translate' is a span she wrote in her own language, rendered into English —
// not a correction. The server decides the label from the span itself, never from
// the model, so the client can trust it (see suggestions/language.go).
export type SuggestionType =
| 'grammar'
| 'phrasing'
| 'idiom'
| 'clarity'
| 'translate'
| 'voice'
| 'collocation'
| 'mechanics'
// One word in the vocabulary garden: a looked-up word with its gloss/phonetic,
// the sentence it was met in, and its spaced-repetition state. `reps` drives how
// "bloomed" its blossom looks; `due_at` decides when it next surfaces for review.
export interface VocabWord {
id: string
word: string
gloss: string
definition: string // English fallback meaning shown in review when there's no gloss
phonetic: string
example: string
doc_id: string | null
// The language of the document this word was met in — the card's own language
// (migration 0018). Read-aloud needs it: "comum" is unguessable from letters.
lang: DocLang
due_at: string
interval_days: number
ease: number
reps: number
lapses: number
last_reviewed: string | null
created_at: string
}
// The self-assessment grades a flashcard review can record.
export type VocabGrade = 'again' | 'good' | 'easy'
// A point-in-time snapshot of a document. List responses omit the heavy
// content/content_text fields; they arrive only on getVersion (preview/restore).
export interface DocumentVersion {
id: string
doc_id: string
title: string
content?: string
content_text?: string
word_count: number
kind: 'auto' | 'manual' | 'pre_restore'
created_at: string
}
// Downloadable export formats. PDF is handled client-side via the browser's
// print dialog (CJK-safe, no server-side font embedding).
export type ExportFormat = 'md' | 'html' | 'txt' | 'docx'
// A single LLM-proposed edit. `original` is the source of truth for placement —
// the editor re-anchors by matching this string in the live document (spec
// Note #6); from_pos/to_pos are server-side advisory only. `replacement` is
// empty for voice flags (awareness-only, no correction to apply).
export interface Suggestion {
id: string
doc_id: string
from_pos: number
to_pos: number
original: string
replacement: string
explanation: string
type: SuggestionType
status: 'pending' | 'accepted' | 'rejected'
// Which engine proposed it — the offline rule pack or the model. The rail
// deliberately renders both identically; this is here because the wire format
// carries it, not because the writer is ever shown it.
source?: 'llm' | 'local'
created_at: string
}
// The growth journal (GET /api/suggestions/growth). `kept`/`kept_before` are
// the last thirty days and the thirty before them — the only comparison Petal
// draws is with her own past self. `stuck` is phrasing she was given that now
// turns up across her own documents; `faded` is what she used to be corrected
// on and hasn't been lately. Both lists are empty when the data isn't there:
// nothing here is padded to fill a page.
export interface GrowthJournal {
kept: number
kept_before: number
stuck: { phrase: string; docs: number }[]
faded: { pattern: string; times: number }[]
}
// A deterministic, rule-based fix detected client-side (see Companion/prose.ts).
// The frontend owns offline detection; the backend only persists these. Spans
// are exact plaintext offsets. `type` names the family the finding belongs to:
// 'mechanics' for a fix to this sentence, 'collocation' for the miscollocation
// rules, whose findings are chunks worth keeping and are filed — and planted in
// the garden on accept — exactly like the LLM coach's.
export interface MechanicsFinding {
from: number
to: number
original: string
replacement: string
explanation: string
type: 'mechanics' | 'collocation'
}
// One dictionary's worth of personal words — the ones she's excused from
// spell-check. Keyed by the dictionary's language, not the writer's.
export interface PersonalWords {
lang: string
words: string[]
}
// One pronunciation of a Chinese word, and what it means in that pronunciation.
// A list, because 得 is dé "to obtain" and also the particle in 说得很好.
export interface HanziReading {
pinyin: string
senses: string
}
// A Chinese word lookup. `readings` is empty for a word with no headword, in
// which case `chars` may carry the character-by-character reading.
export interface HanziInfo {
word: string
readings: HanziReading[]
chars: { char: string; pinyin: string; senses: string }[]
}
// Who's writing. Mirrors the backend db.User.
export interface Me {
id: string
email: string
display_name: string
created_at: string
pair_lang: string
// Which half of the pair is being learned: 'learning_en' (the writer is
// native in pair_lang and practising English) or 'learning_pair' (the other
// way round). Mirrors users.direction; the server refuses 'learning_pair' for
// a pair it has no word list for.
direction: string
}
// Thrown when the server says the session is gone. Callers can tell it apart
// from a real failure — losing your session is not the same as a save going
// wrong, and the auto-save has to treat them very differently.
export class UnauthorizedError extends Error {
constructor() {
super('not signed in')
this.name = 'UnauthorizedError'
}
}
// Sessions expire, so *any* call can come back 401 — including the auto-save
// that fires 1.5s after every keystroke. One place notices, and the app reacts
// once, rather than each call site inventing its own answer.
let unauthorizedHandler: (() => void) | null = null
export function onUnauthorized(handler: () => void) {
unauthorizedHandler = handler
}
function signedOut(): UnauthorizedError {
unauthorizedHandler?.()
return new UnauthorizedError()
}
// The document-language verdict is decided by the checkpoint pass, so the pass's
// own response is the first moment the client can know it. It rides on a header
// (the pass answers with a bare array of suggestions, and every caller reads it
// as one), and reaches the app through a handler registered here — the same
// shape onUnauthorized already uses, for the same reason: it is one fact from
// deep inside a request that a component several layers up needs.
//
// Without it the editor learns the verdict only from a document save, which is
// always one save behind the pass — and read-aloud is reached for precisely when
// she has stopped typing and no further save is coming.
let docLangHandler: ((docId: string, lang: DocLang) => void) | null = null
export function onDocLang(handler: (docId: string, lang: DocLang) => void) {
docLangHandler = handler
}
// `verdictFor` names the document whose language this response may announce.
// Only the three pass endpoints pass it; everything else has no verdict to carry
// and never touches the handler.
async function req<T>(path: string, init?: RequestInit, verdictFor?: string): Promise<T> {
const res = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' },
...init,
})
if (verdictFor && res.ok) {
const lang = res.headers.get('X-Petal-Doc-Lang')
if (lang === '' || lang === 'en' || lang === 'pair') docLangHandler?.(verdictFor, lang)
}
if (res.status === 401) throw signedOut()
if (!res.ok) {
const detail = await res.text().catch(() => '')
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
}
if (res.status === 204) return undefined as T
return res.json() as Promise<T>
}
export const api = {
// The signed-in writer. With auth unconfigured (local development) this is
// the hardcoded local user, so the frontend needs no separate mode for it.
me: () => req<Me>('/me'),
// Move to another (English + X) pair. Answers with the whole updated user, so
// the caller re-reads the pair from the server rather than assuming its own
// request took — a code the server won't ship comes back 400 and the app is
// still on a language it can render.
setPairLang: (lang: string) =>
req<Me>('/me', { method: 'PATCH', body: JSON.stringify({ pair_lang: lang }) }),
// Turn the pair around. Same endpoint, same contract, and deliberately a
// separate call: the two fields are validated together server-side, so a
// client that wants to change both says both in one request rather than
// sending two that each pass on their own.
setDirection: (direction: string) =>
req<Me>('/me', { method: 'PATCH', body: JSON.stringify({ direction }) }),
setPair: (lang: string, direction: string) =>
req<Me>('/me', { method: 'PATCH', body: JSON.stringify({ pair_lang: lang, direction }) }),
listDocs: () => req<DocSummary[]>('/docs'),
createDoc: () => req<Document>('/docs', { method: 'POST' }),
getDoc: (id: string) => req<Document>(`/docs/${id}`),
updateDoc: (id: string, body: DocUpdate) =>
req<Document>(`/docs/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteDoc: (id: string) => req<void>(`/docs/${id}`, { method: 'DELETE' }),
// Grammar checkpoint: runs an LLM pass and returns the fresh pending set.
// Rate-limited per document server-side (returns the existing set if too soon).
// Both passes return the UNIFIED pending set (grammar + voice), so the client
// never drops one family's highlights when the other refreshes.
checkDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/check`, { method: 'POST' }, id),
// Voice-consistency pass: whole-document, explicit-action, slower. Returns the
// unified pending set too. Rate-limited per document server-side.
voiceDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/voice`, { method: 'POST' }, id),
// Collocation coach: whole-document, explicit-action pass flagging non-native
// word pairings ("do a decision" → "make a decision"). Returns the unified
// pending set too. Rate-limited per document server-side.
collocationDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/collocation`, { method: 'POST' }, id),
// Mechanics pass: persist the client-detected deterministic fixes as the
// 'mechanics' family and return the unified pending set. Not rate-limited (it's
// free, local detection); runs alongside the grammar checkpoint.
submitMechanics: (id: string, findings: MechanicsFinding[]) =>
req<Suggestion[]>(`/docs/${id}/mechanics`, {
method: 'POST',
body: JSON.stringify({ findings }),
}),
// Pending suggestions for a doc, loaded when the editor opens it.
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
// The spans she has already accepted or dismissed on this doc, normalized. The
// server suppresses these itself; the client needs them so the instant rule-pack
// pass doesn't hand back a dismissed card before the server can say otherwise —
// or, with the server unreachable, at all. See lib/settled.ts.
listSettled: (id: string) =>
req<{ originals: string[] }>(`/docs/${id}/settled`),
acceptSuggestion: (id: string) =>
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
dismissSuggestion: (id: string) =>
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
// Simplified-Chinese rendering of a suggestion's explanation, for the Ask Petal
// opening bubble (the explanation itself stays English in the card body).
translateSuggestion: (id: string) =>
req<{ translation: string }>(`/suggestions/${id}/translate`, { method: 'POST' }),
// The growth journal: her own accepted edits read back as patterns. Purely a
// read-side view of a table Petal already keeps, computed locally with no
// model call, so it costs nothing and leaves nothing.
growth: () => req<GrowthJournal>('/suggestions/growth'),
// Version history. listVersions returns metadata only (no bodies); getVersion
// loads one full snapshot for preview; snapshotDoc takes an explicit restore
// point; restoreVersion copies a snapshot back onto the live doc (capturing a
// pre_restore safety copy server-side first) and returns the restored doc.
listVersions: (id: string) => req<DocumentVersion[]>(`/docs/${id}/versions`),
getVersion: (id: string, vid: string) =>
req<DocumentVersion>(`/docs/${id}/versions/${vid}`),
snapshotDoc: (id: string) =>
req<DocumentVersion>(`/docs/${id}/versions`, { method: 'POST' }),
restoreVersion: (id: string, vid: string) =>
req<Document>(`/docs/${id}/versions/${vid}/restore`, { method: 'POST' }),
// Download URL for an exported document (md/html/txt/docx). Used as an <a
// href download> target so the browser handles the file save.
exportUrl: (id: string, format: ExportFormat) =>
`/api/docs/${id}/export?format=${format}`,
// Download URL for a whole-corpus backup: a zip of every document rendered in
// the given format. A one-click "download all my writing" safety net.
exportAllUrl: (format: ExportFormat) => `/api/docs/export-all?format=${format}`,
// Download URL for the writing passport: a standalone HTML report of how this
// document was written (timeline, growth, sessions), for showing someone who
// questions its authorship. Print to PDF from the browser to hand it over.
passportUrl: (id: string) => `/api/docs/${id}/passport`,
// Offline word lookup (gloss + definition + synonyms) for the right-click popover.
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
// Lightweight Chinese-only gloss for the inline hover/select tooltip — instant
// and offline, so it fires on hover without spinning up the heavier lookup.
glossWord: (word: string) => req<Gloss>(`/gloss/${encodeURIComponent(word)}`),
// The same lookup pointing the other way: a Chinese word to its pinyin and
// English senses, for an account learning the pair language rather than
// English. A word the dictionary has no headword for comes back with empty
// readings and — when its characters are known — a per-character reading
// instead, which is a real second answer for a compound.
hanziWord: (word: string) => req<HanziInfo>(`/hanzi/${encodeURIComponent(word)}`),
// Tone-rewrite: rewrites a selected passage in the given style ('natural',
// 'academic', …) and returns the rewritten text for an in-editor preview. Not
// persisted — the editor applies it directly on accept.
rewriteSelection: (docId: string, text: string, style: string) =>
req<{ rewrite: string }>(`/docs/${docId}/rewrite`, {
method: 'POST',
body: JSON.stringify({ text, style }),
}),
// Cross-document full-text search (title + body). Returns hits with a
// highlighted snippet. Empty query returns []. Encodes the term for the URL.
search: (q: string) => req<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`),
// Image upload: posts a single image file as multipart form data and returns
// its served URL (e.g. /api/images/<hash>.png). Stored on disk by the binary,
// so the document JSON keeps a small URL reference instead of inlined base64.
// Doesn't go through req() because the body is FormData, not JSON.
uploadImage: async (file: File): Promise<{ url: string }> => {
const form = new FormData()
form.append('image', file)
const res = await fetch('/api/images', { method: 'POST', body: form })
if (res.status === 401) throw signedOut()
if (!res.ok) {
const detail = await res.text().catch(() => '')
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
}
return res.json() as Promise<{ url: string }>
},
// Tags. listTags returns the roster with per-tag document counts; createTag is
// idempotent on name (returns the existing tag if it already exists);
// updateTag recolors/renames; deleteTag removes it (assignments cascade).
// assignTag/unassignTag link a tag to one document.
listTags: () => req<Tag[]>('/tags'),
createTag: (name: string, color: TagColor) =>
req<Tag>('/tags', { method: 'POST', body: JSON.stringify({ name, color }) }),
updateTag: (id: string, patch: { name?: string; color?: TagColor }) =>
req<Tag>(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
deleteTag: (id: string) => req<void>(`/tags/${id}`, { method: 'DELETE' }),
assignTag: (docId: string, tagId: string) =>
req<void>(`/docs/${docId}/tags`, { method: 'POST', body: JSON.stringify({ tag_id: tagId }) }),
unassignTag: (docId: string, tagId: string) =>
req<void>(`/docs/${docId}/tags/${tagId}`, { method: 'DELETE' }),
// Vocabulary garden. recordVocab captures (or refreshes) a looked-up word —
// idempotent per word, fired automatically on lookup. listVocab is the whole
// garden; dueVocab is just the cards ready for review; reviewVocab grades one
// card and returns its rescheduled state; deleteVocab removes a word.
recordVocab: (body: {
word: string
gloss?: string
definition?: string
phonetic?: string
example?: string
doc_id?: string | null
}) => req<VocabWord>('/vocab', { method: 'POST', body: JSON.stringify(body) }),
listVocab: () => req<VocabWord[]>('/vocab'),
dueVocab: () => req<VocabWord[]>('/vocab/due'),
reviewVocab: (id: string, grade: VocabGrade) =>
req<VocabWord>(`/vocab/${id}/review`, { method: 'POST', body: JSON.stringify({ grade }) }),
deleteVocab: (id: string) => req<void>(`/vocab/${id}`, { method: 'DELETE' }),
// The personal spelling dictionary — words she's told Petal to stop flagging.
// Server-side, so it belongs to her account and follows her between devices.
// `lang` is the *dictionary's* language: an English exception must not silence
// a pt-PT flag. Every call answers with the full resulting list, so the client
// never has to merge two views of the same set.
listPersonalWords: (lang: string) =>
req<PersonalWords>(`/spell/words?lang=${encodeURIComponent(lang)}`),
addPersonalWords: (lang: string, words: string[]) =>
req<PersonalWords>('/spell/words', {
method: 'POST',
body: JSON.stringify({ lang, words }),
}),
removePersonalWord: (lang: string, word: string) =>
req<PersonalWords>(
`/spell/words?lang=${encodeURIComponent(lang)}&word=${encodeURIComponent(word)}`,
{ method: 'DELETE' },
),
// Current deployed build id — changes whenever a new frontend ships. The
// app polls this to offer a refresh. Bypasses any cache so the answer is live.
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
}
// The sentinel characters the server wraps a search match in (\x01 … \x02). Used
// by splitSnippet to render the matched span highlighted.
export const SNIPPET_HL_START = String.fromCharCode(1)
export const SNIPPET_HL_END = String.fromCharCode(2)
// splitSnippet breaks a server snippet into ordered { text, hit } segments so the
// UI can bold the matched span(s) without dangerously setting innerHTML.
export function splitSnippet(snippet: string): { text: string; hit: boolean }[] {
const out: { text: string; hit: boolean }[] = []
let rest = snippet
for (;;) {
const start = rest.indexOf(SNIPPET_HL_START)
if (start < 0) {
if (rest) out.push({ text: rest, hit: false })
break
}
if (start > 0) out.push({ text: rest.slice(0, start), hit: false })
const end = rest.indexOf(SNIPPET_HL_END, start + 1)
if (end < 0) {
// Unterminated (shouldn't happen) — emit the remainder plainly.
out.push({ text: rest.slice(start + 1), hit: false })
break
}
out.push({ text: rest.slice(start + 1, end), hit: true })
rest = rest.slice(end + 1)
}
return out
}
// tagColorVar maps a tag color key to its CSS custom property. Unknown keys fall
// back to the rose accent, matching the backend's coercion.
export function tagColorVar(color: TagColor | string): string {
switch (color) {
case 'mint':
return 'var(--color-mint)'
case 'peach':
return 'var(--color-peach)'
case 'lavender':
return 'var(--color-lavender)'
case 'sky':
return 'var(--color-sky)'
case 'honey':
return 'var(--color-honey)'
default:
return 'var(--color-accent)'
}
}
// One turn in an Ask Petal conversation. History lives only in the component —
// the server is stateless and re-injects the suggestion context every request.
export interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
// streamSuggestionChat POSTs the conversation to the SSE chat endpoint and
// invokes onToken for each text chunk as it arrives. It uses fetch + a
// ReadableStream reader (not EventSource, which can't POST) and resolves when
// the stream ends. Abort via the optional signal to cancel mid-response.
export async function streamSuggestionChat(
suggestionId: string,
messages: ChatMessage[],
onToken: (text: string) => void,
signal?: AbortSignal,
): Promise<void> {
const res = await fetch(`/api/suggestions/${suggestionId}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
signal,
})
if (res.status === 401) throw signedOut()
if (!res.ok || !res.body) {
const detail = await res.text().catch(() => '')
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
// SSE events are separated by a blank line. Process every complete one and
// keep the trailing partial in the buffer.
let sep: number
while ((sep = buf.indexOf('\n\n')) >= 0) {
const event = parseSSE(buf.slice(0, sep))
buf = buf.slice(sep + 2)
if (event.name === 'done') return
if (event.name === 'token' && event.data) {
const text = (JSON.parse(event.data) as { text: string }).text
if (text) onToken(text)
}
}
}
}
// parseSSE pulls the event name and data payload out of one raw SSE frame.
function parseSSE(frame: string): { name: string; data: string } {
let name = 'message'
const dataLines: string[] = []
for (const line of frame.split('\n')) {
if (line.startsWith('event:')) name = line.slice(6).trim()
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
}
return { name, data: dataLines.join('\n') }
}