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
616 lines
26 KiB
TypeScript
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') }
|
|
}
|