// 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 } // 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 } // 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 // Chinese translation; '' when the word isn't in the gloss set phonetic: string // IPA for the English word; '' when absent definitions: WordMeaning[] synonyms: string[] } // The lightweight Chinese-only gloss behind the inline hover/select tooltip. export interface Gloss { word: string gloss: string } export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' | 'collocation' // 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 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' created_at: string } async function req(path: string, init?: RequestInit): Promise { const res = await fetch(`/api${path}`, { headers: { 'Content-Type': 'application/json' }, ...init, }) 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 } export const api = { listDocs: () => req('/docs'), createDoc: () => req('/docs', { method: 'POST' }), getDoc: (id: string) => req(`/docs/${id}`), updateDoc: (id: string, body: DocUpdate) => req(`/docs/${id}`, { method: 'PUT', body: JSON.stringify(body) }), deleteDoc: (id: string) => req(`/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(`/docs/${id}/check`, { method: 'POST' }), // Voice-consistency pass: whole-document, explicit-action, slower. Returns the // unified pending set too. Rate-limited per document server-side. voiceDoc: (id: string) => req(`/docs/${id}/voice`, { method: 'POST' }), // 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(`/docs/${id}/collocation`, { method: 'POST' }), // Pending suggestions for a doc, loaded when the editor opens it. listSuggestions: (id: string) => req(`/docs/${id}/suggestions`), acceptSuggestion: (id: string) => req(`/suggestions/${id}/accept`, { method: 'POST' }), dismissSuggestion: (id: string) => req(`/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' }), // 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(`/docs/${id}/versions`), getVersion: (id: string, vid: string) => req(`/docs/${id}/versions/${vid}`), snapshotDoc: (id: string) => req(`/docs/${id}/versions`, { method: 'POST' }), restoreVersion: (id: string, vid: string) => req(`/docs/${id}/versions/${vid}/restore`, { method: 'POST' }), // Download URL for an exported document (md/html/txt/docx). Used as an 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}`, // Offline word lookup (gloss + definition + synonyms) for the right-click popover. lookupWord: (word: string) => req(`/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/${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(`/search?q=${encodeURIComponent(q)}`), // Image upload: posts a single image file as multipart form data and returns // its served URL (e.g. /api/images/.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.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('/tags'), createTag: (name: string, color: TagColor) => req('/tags', { method: 'POST', body: JSON.stringify({ name, color }) }), updateTag: (id: string, patch: { name?: string; color?: TagColor }) => req(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }), deleteTag: (id: string) => req(`/tags/${id}`, { method: 'DELETE' }), assignTag: (docId: string, tagId: string) => req(`/docs/${docId}/tags`, { method: 'POST', body: JSON.stringify({ tag_id: tagId }) }), unassignTag: (docId: string, tagId: string) => req(`/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('/vocab', { method: 'POST', body: JSON.stringify(body) }), listVocab: () => req('/vocab'), dueVocab: () => req('/vocab/due'), reviewVocab: (id: string, grade: VocabGrade) => req(`/vocab/${id}/review`, { method: 'POST', body: JSON.stringify({ grade }) }), deleteVocab: (id: string) => req(`/vocab/${id}`, { 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 { const res = await fetch(`/api/suggestions/${suggestionId}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages }), signal, }) 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') } }