Files
petal/petal-spec.md
prosolis e72d48d64e Phase 0: project scaffold
- Go module + chi server with embedded SPA serving and /api/health
- internal/config env loader (local-dev defaults; auth/copyleaks deferred)
- React 19 + Vite 6 + Tailwind v4 frontend with full Petal design tokens
- Frontend embedded into the binary via web/embed.go (go:embed all:dist)
- README dev workflow, .env.example, BUILD_PLAN progress tracker
- Verified end-to-end: binary serves health, embedded SPA, and SPA fallback
2026-06-25 20:20:04 -07:00

37 KiB
Raw Permalink Blame History

Petal — AI Writing Assistant

Project Specification for Claude Code


Overview

Petal is a self-hosted, privacy-first writing editor for an ESL user. It replaces Grammarly with a warm, bubbly web app that combines Tiptap rich text editing, auto-save cloud storage, and periodic AI-powered grammar/ESL suggestions backed by a local vLLM inference endpoint.

Single-user initially (wife's Authentik account). Deployed to write.parodia.dev.


Tech Stack

Layer Choice Why
Frontend React + Vite Tiptap is React-native; best extension ecosystem
Editor Tiptap v2 ProseMirror with sane API; supports custom marks/decorations
Styling Tailwind v4 Utility-first; pairs well with custom design tokens
Backend Go + chi Consistent with rest of stack (PGX Comics, Veola)
Database SQLite (modernc — pure Go, no cgo) Simple, single-binary friendly
Spell check nspell (JS, browser-side) Zero latency, no backend call, loads hunspell dictionaries
AI suggestions vLLM HTTP endpoint Already running; configurable model + base URL
Auth Authentik OIDC Already running on parodia.dev
Deployment Docker, Traefik Consistent with existing infra

Design System

Aesthetic: soft, warm, bubbly. The UI should feel like a cozy stationery app, not a productivity tool.

Color Palette

--color-bg:           #FDF6F0   /* warm cream canvas */
--color-surface:      #FFFFFF   /* card/panel surfaces */
--color-surface-alt:  #FFF0F5   /* rose-tinted alt surface */
--color-border:       #F0E0EB   /* soft pink-grey border */
--color-text:         #3D2E39   /* warm dark plum, not harsh black */
--color-text-muted:   #9B8CA3   /* muted lavender-grey */
--color-accent:       #E8A0BF   /* soft rose — primary interactive */
--color-accent-hover: #D98AAF   /* slightly deeper on hover */
--color-mint:         #A8D8C8   /* suggestion type: grammar */
--color-peach:        #F4B8A0   /* suggestion type: phrasing */
--color-lavender:     #C5B4E8   /* suggestion type: idiom */
--color-sky:          #A8CCE8   /* suggestion type: clarity */
--color-honey:        #CE9B4F   /* suggestion type: voice — deepened amber for underline contrast on cream */
--color-success:      #8FCFA8   /* saved, accepted */
--color-shadow:       rgba(180, 130, 160, 0.12) /* warm-tinted shadow */

Typography

  • UI / Headings: Nunito (Google Fonts) — rounded, bubbly, friendly
  • Editor body: Lora (Google Fonts) — warm serif, reads beautifully at paragraph length
  • Monospace / data: JetBrains Mono (used sparingly for word counts etc.)

Shape Language

  • Border radius: 16px on cards, 24px on modals, 999px on buttons/pills, 12px on input fields
  • Shadows: 0 4px 20px var(--color-shadow) — soft, warm, lifted
  • Transitions: 200ms ease across all interactive elements

Signature Element

Suggestion decorations animate in with a gentle fade + slight upward float (translateY 4px → 0). The acceptance animation plays a tiny confetti burst (CSS only, 34 colored dots). The pulsing LLM checkpoint indicator is a soft rose dot that breathes (opacity 0.4 → 1 → 0.4 in 2s).


Directory Structure

petal/
├── cmd/
│   └── server/
│       └── main.go              # Entry point; embeds /web/dist
├── internal/
│   ├── config/
│   │   └── config.go            # Env var loading
│   ├── db/
│   │   ├── db.go                # SQLite init + migrations
│   │   └── models.go            # Document, Suggestion, User types
│   ├── auth/
│   │   ├── oidc.go              # Authentik OIDC flow
│   │   └── middleware.go        # Session auth middleware
│   ├── docs/
│   │   └── handlers.go          # Document CRUD handlers
│   ├── plagiarism/
│   │   ├── copyleaks.go         # Copyleaks API client
│   │   └── voice.go             # Local LLM voice consistency check
│   └── llm/
│       ├── client.go            # LLMClient interface + factory function
│       ├── vllm.go              # vLLM concrete implementation (OpenAI-compat)
│       ├── ollama.go            # Ollama concrete implementation (native API)
│       ├── checkpoint.go        # Checkpoint trigger + debounce logic
│       └── prompts.go           # System prompts
├── web/
│   ├── src/
│   │   ├── components/
│   │   │   ├── Editor/
│   │   │   │   ├── EditorCore.tsx        # Tiptap instance + config
│   │   │   │   ├── SuggestionMark.tsx    # Custom Tiptap mark extension
│   │   │   │   ├── SuggestionCard.tsx    # Hover card (suggestion + explanation)
│   │   │   │   └── CheckpointIndicator.tsx
│   │   │   ├── DocList/
│   │   │   │   ├── DocList.tsx           # Document browser sidebar
│   │   │   │   └── DocListItem.tsx
│   │   │   ├── Toolbar/
│   │   │   │   └── Toolbar.tsx           # Formatting toolbar
│   │   │   └── StatusBar/
│   │   │       └── StatusBar.tsx         # Word count, save status, checkpoint dot
│   │   ├── hooks/
│   │   │   ├── useAutoSave.ts            # Debounced save (1.5s)
│   │   │   └── useCheckpoint.ts          # LLM checkpoint trigger (4s pause)
│   │   ├── api/
│   │   │   └── client.ts                 # Fetch wrappers for all API routes
│   │   ├── App.tsx
│   │   └── main.tsx
│   ├── index.html
│   ├── package.json
│   ├── vite.config.ts
│   └── tailwind.config.ts
├── Dockerfile
├── docker-compose.yml
├── .env.example
└── README.md

Database Schema

CREATE TABLE users (
    id          TEXT PRIMARY KEY,   -- Authentik subject claim
    email       TEXT NOT NULL,
    display_name TEXT,
    created_at  DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE documents (
    id           TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
    user_id      TEXT NOT NULL REFERENCES users(id),
    title        TEXT NOT NULL DEFAULT 'Untitled',
    content      TEXT NOT NULL DEFAULT '{}',  -- Tiptap JSON
    content_text TEXT NOT NULL DEFAULT '',    -- Plain text for LLM
    word_count   INTEGER NOT NULL DEFAULT 0,
    created_at   DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at   DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE suggestions (
    id           TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
    doc_id       TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    from_pos     INTEGER NOT NULL,
    to_pos       INTEGER NOT NULL,
    original     TEXT NOT NULL,
    replacement  TEXT NOT NULL,
    explanation  TEXT NOT NULL,
    type         TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice')),
    status       TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
    created_at   DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE plagiarism_reports (
    id              TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
    doc_id          TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    backend         TEXT NOT NULL DEFAULT 'copyleaks',
    similarity_pct  REAL,                -- overall similarity score 0100
    status          TEXT NOT NULL DEFAULT 'pending'
                    CHECK(status IN ('pending','complete','error')),
    result_json     TEXT,                -- full Copyleaks response, stored raw
    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);

API Routes

All routes under /api/. Auth middleware validates session cookie on all except /api/auth/*.

GET    /api/me                    → current user info
POST   /api/auth/login            → redirect to Authentik
GET    /api/auth/callback         → OIDC callback, set session cookie
POST   /api/auth/logout           → clear session

GET    /api/docs                  → list user's documents (id, title, word_count, updated_at)
POST   /api/docs                  → create new document → returns full document
GET    /api/docs/:id              → get document + pending suggestions
PUT    /api/docs/:id              → update document content (auto-save endpoint)
DELETE /api/docs/:id              → delete document

POST   /api/docs/:id/check        → run grammar checkpoint (returns new suggestions)
POST   /api/docs/:id/voice        → run voice-consistency pass (returns voice suggestions)

POST   /api/docs/:id/plagiarism        → trigger Copyleaks check (returns report id)
GET    /api/docs/:id/plagiarism/latest → get latest report for document
PUT    /api/suggestions/:id       → update suggestion status (accepted/rejected)
DELETE /api/docs/:id/suggestions  → clear all suggestions for a doc
POST   /api/suggestions/:id/chat  → Ask Petal conversational follow-up (SSE stream)

Plagiarism Detection

Architecture: Two Tiers

Tier 1 — Voice Consistency Check (local, LLM, separate pass) The LLM reviews the document for passages that feel tonally inconsistent with the rest of her writing — formulaic phrasing, suspiciously polished sentences, register shifts. Surfaces these as a new suggestion type (type: "voice") with a warm honey-amber decoration (--color-honey, distinct from the lavender used for idiom; deepened from a pale yellow so the underline stays legible against the cream canvas). This is not plagiarism detection; it's a writing consistency tool. It runs locally with no privacy cost and is useful for ESL writers who may have paraphrased too closely from a source.

This is a distinct pass from the grammar checkpoint — do not bundle them. Voice-consistency detection is inherently whole-document (it compares a passage against the established voice everywhere else), so it needs the full document as context and the full window is available (see Context Window Management). That makes it too expensive to fire on the fast 4s checkpoint cadence. Run it instead either:

  • on a slow cadence (every 3060s of idle), separate from the grammar checkpoint debounce, or
  • on an explicit "Check my voice" action in the toolbar.

Send the full content_text (no trailing-window truncation — the model window is 256K, the document will fit). Use a higher max_tokens than the grammar checkpoint since it may flag several passages. The grammar checkpoint stays fast and local-context; voice runs slow and whole-document. Each uses the context size and cadence appropriate to its job.

Tier 2 — Academic Plagiarism Check (Copyleaks, opt-in, manual) A dedicated "Check for plagiarism" button in the editor toolbar. On first use, shows a consent modal explaining that document text will be sent to Copyleaks servers for comparison against web and academic databases. User must explicitly confirm. After confirmation, preference is saved per-user and the modal does not reappear.

Copyleaks checks against web content and academic paper databases — the same corpus type schools use with Turnitin. Not identical to Turnitin, but meaningful for pre-submission review.

Copyleaks Integration

Env vars:

COPYLEAKS_ENABLED=false          # off by default
COPYLEAKS_API_KEY=
COPYLEAKS_EMAIL=                 # account email, required by their API

API flow: Copyleaks uses an async model — you submit text, get a scan ID, then poll or receive webhook callback when complete.

1. POST https://api.copyleaks.com/v3/education/submit/url/{scanId}
   (or text submission endpoint)
   → 201 Accepted, scanId stored in plagiarism_reports

2. Copyleaks calls webhook: POST /api/webhooks/copyleaks
   → Go handler updates plagiarism_reports with result_json + similarity_pct + status=complete

3. Frontend polls GET /api/docs/:id/plagiarism/latest every 5s
   while status=pending, renders results when complete

Webhook endpoint: POST /api/webhooks/copyleaks — must be publicly reachable (it is, via write.parodia.dev). No auth middleware on this route; validate using Copyleaks HMAC signature header instead.

Results display (PlagiarismPanel component):

  • Slide-in right panel (same pattern as AskPetal expanded card but full height)
  • Top: large similarity percentage with color coding (green <15%, amber 1530%, red >30%)
  • Below: list of matched sources with URL, matched percentage, and matched passage
  • Matched passages highlighted in the editor with a new red-tinted decoration type
  • "Last checked: X minutes ago" + "Re-check" button
  • Privacy reminder at panel footer: "Text was sent to Copyleaks for this check"

Voice Consistency Prompt Addition

Add to the checkpoint system prompt (after existing ESL instructions):

Also identify any passages (2+ sentences) that feel tonally inconsistent with 
the surrounding writing — unusually formal, unusually polished, or phrased in 
a way that differs from the writer's established voice elsewhere in the document. 
Flag these with type "voice". Do not flag the first paragraph (no baseline yet).

Return format addition:

{ "original": "...", "replacement": null, "explanation": "This passage sounds more formal than the rest of your writing — worth reviewing.", "type": "voice" }

replacement is null for voice flags — there is no correction, just awareness. The frontend SuggestionCard handles null replacement by hiding the replacement row and showing only the explanation + Dismiss.


LLM Integration

Client Config (env vars)

LLM_BACKEND=vllm           # vllm | ollama — default: vllm
LLM_ENDPOINT=http://192.168.x.x:8000   # vLLM default port 8000, Ollama default 11434
LLM_MODEL=gemma4           # Checkpoint model — small, fast. Treat as config, never hardcode.
LLM_CHAT_MODEL=gemma4      # Ask Petal model — can be much larger. Defaults to LLM_MODEL if unset.
LLM_TIMEOUT=30s

Model sizing guidance: Checkpoint runs every 4 seconds while the user types — latency is UX. Keep LLM_MODEL at 4B7B. Gemma4 is a solid default.

Ask Petal is a deliberate conversational turn with small input context (~2,000 tokens) and short output (24 sentences). Since the user asks questions in Mandarin, LLM_CHAT_MODEL should be a Chinese-native model — Qwen3.5 9B is the right pick. Native Mandarin capability rather than learned multilingual coverage makes a real difference for grammar explanation quality. With 64GB VRAM you have plenty of headroom for a larger Qwen3 variant.

If LLM_CHAT_MODEL is unset, fall back to LLM_MODEL. Single-model setups work fine.

Backend Abstraction

Define a LLMClient interface in internal/llm/client.go. All handler code calls the interface only — never a concrete type directly.

// internal/llm/client.go

type Message struct {
    Role    string `json:"role"`
    Content string `json:"content"`
}

type CompletionRequest struct {
    Messages          []Message
    MaxTokens         int
    Temperature       float64
    RepetitionPenalty float64
    TopP              float64
    Stop              []string
    Stream            bool
}

type LLMClient interface {
    // Complete returns the full response in one shot (used for checkpoint JSON)
    Complete(ctx context.Context, req CompletionRequest) (string, error)
    // Stream returns a channel of text chunks (used for Ask Petal SSE)
    Stream(ctx context.Context, req CompletionRequest) (<-chan string, error)
}

// Factory — selects backend from config
func NewLLMClient(cfg *config.Config) LLMClient {
    // LLM_CHAT_MODEL falls back to LLM_MODEL if unset
    chatModel := cfg.LLMChatModel
    if chatModel == "" {
        chatModel = cfg.LLMModel
    }
    switch cfg.LLMBackend {
    case "ollama":
        return &OllamaClient{
            endpoint:       cfg.LLMEndpoint,
            checkpointModel: cfg.LLMModel,
            chatModel:       chatModel,
            timeout:         cfg.LLMTimeout,
        }
    default: // "vllm"
        return &VLLMClient{
            endpoint:        cfg.LLMEndpoint,
            checkpointModel: cfg.LLMModel,
            chatModel:       chatModel,
            timeout:         cfg.LLMTimeout,
        }
    }
}

internal/llm/vllm.go — OpenAI-compatible endpoint:

POST {endpoint}/v1/chat/completions
Content-Type: application/json

{
  "model": "{model}",
  "messages": [...],
  "max_tokens": {MaxTokens},
  "temperature": {Temperature},
  "repetition_penalty": {RepetitionPenalty},
  "top_p": {TopP},
  "stop": [...],
  "stream": {Stream}
}

Streaming: SSE, parse data: lines, extract choices[0].delta.content, stop on data: [DONE].

internal/llm/ollama.go — Ollama native API:

POST {endpoint}/api/chat
Content-Type: application/json

{
  "model": "{model}",
  "messages": [...],
  "stream": {Stream},
  "options": {
    "num_predict": {MaxTokens},
    "temperature": {Temperature},
    "repeat_penalty": {RepetitionPenalty},
    "top_p": {TopP},
    "stop": [...]
  }
}

Streaming: newline-delimited JSON objects, extract message.content, stop when done: true.

Parameter mapping cheatsheet:

CompletionRequest field vLLM key Ollama key
MaxTokens max_tokens options.num_predict
Temperature temperature options.temperature
RepetitionPenalty repetition_penalty options.repeat_penalty
TopP top_p options.top_p
Stop stop options.stop

Both backends live behind the same interface — checkpoint.go and the Ask Petal handler call client.Complete() and client.Stream() with no awareness of which backend is active.

Checkpoint Logic

  • Trigger: user stops typing for 4 seconds (debounced in frontend via useCheckpoint.ts)
  • Rate limit: backend enforces minimum 30s between checks per document
  • Payload: full content_text of the document (not just the changed section — context matters for ESL)
  • Max tokens: 1024 (suggestions are structured JSON, should be compact)
  • Visual feedback: pulsing rose dot in StatusBar while check in flight

System Prompt

You are a warm, encouraging writing assistant helping someone who speaks English as a second language. 
Analyze the text below and identify up to 5 issues: grammar errors, unnatural phrasing, 
incorrect idiom usage, or unclear sentences that are common ESL patterns.

Be specific, friendly, and explain WHY each suggestion improves the writing.

Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
{
  "suggestions": [
    {
      "original": "exact text from the document that needs fixing",
      "replacement": "corrected version",
      "explanation": "friendly one-sentence explanation",
      "type": "grammar|phrasing|idiom|clarity"
    }
  ]
}

If the writing looks good, return: {"suggestions": []}

Ask Petal: Conversational Follow-Up

When a user doesn't understand a suggestion, they tap "Ask Petal " inside the suggestion card. This opens a mini chat panel anchored to the suggestion card (expands downward, max-height 320px, scrollable). The conversation is maintained in React state — not persisted to the DB. Closing the card clears the history.

API route: POST /api/suggestions/:id/chat

Request payload:

{
  "messages": [
    { "role": "user", "content": "why is this wrong?" }
  ]
}

Server behavior: The Go handler fetches the suggestion record (original, replacement, explanation, type) and the surrounding paragraph from the parent document's content_text. It injects these as system context, then appends the user's message history and streams the response back via SSE.

Response: SSE stream, data: events containing response text chunks. Frontend accumulates into assistant message bubble.

Ask Petal System Prompt:

You are Petal, a warm and patient English writing tutor helping someone who is learning English 
as a second language. You are currently discussing a specific writing suggestion.

Suggestion context:
- Original text: "{{original}}"
- Suggested replacement: "{{replacement}}"  
- Issue type: {{type}}
- Initial explanation: "{{explanation}}"
- Surrounding paragraph: "{{paragraph}}"

The user wants to understand this suggestion better. Detect the language of the user's message 
and respond in that same language. If they write in Mandarin Chinese, respond entirely in 
Mandarin. If they write in English, respond in English. Never mix languages in a single response.

Explain clearly and kindly. Use simple language appropriate to the user's message. Give examples 
when helpful. If they ask "why" (or "为什么"), explain the grammar rule or idiom behind it. 
If they suggest an alternative phrasing, evaluate it honestly.

Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encouraging — 
learning a language is hard and they're doing great.

AskPetal Component (web/src/components/Editor/AskPetal.tsx):

  • Renders inside the expanded SuggestionCard
  • Input field at bottom, message bubbles above (Petal messages: rose-tinted left-aligned; user messages: right-aligned lavender)
  • "Ask Petal " trigger button is a small pill link below the suggestion explanation
  • Petal's first message pre-populates with the suggestion's explanation so context is immediate
  • Streaming response renders token-by-token into the latest assistant bubble
  • No conversation persistence — state lives in the SuggestionCard component

LLM Sampling Parameters & Stability

vLLM is stateless per request. There are no server-side sessions — conversation history for Ask Petal is managed entirely at the app layer (client sends full message array each request). This is correct behavior; do not attempt to implement vLLM-side session persistence.

Checkpoint requests (structured JSON output):

{
  "model": "${LLM_MODEL}",
  "messages": [...],
  "max_tokens": 1024,
  "temperature": 0.3,
  "repetition_penalty": 1.15,
  "top_p": 0.9,
  "stop": ["```", "\n\n\n\n"]
}

Ask Petal chat requests (conversational):

{
  "model": "${LLM_MODEL}",
  "messages": [...],
  "max_tokens": 512,
  "temperature": 0.7,
  "repetition_penalty": 1.15,
  "top_p": 0.92,
  "stop": ["\n\n\n"]
}

Why these values:

  • temperature: 0.3 for checkpoint — low enough to produce structured JSON reliably, but not zero. Zero temperature means greedy decoding, which is paradoxically more prone to repetition loops in smaller models, not less.
  • temperature: 0.7 for chat — conversational range; allows natural variation without going incoherent.
  • repetition_penalty: 1.15 on both — primary defense against repetition breakdown. Applies a multiplicative penalty to already-seen tokens. 1.11.2 is the safe range; above 1.3 starts degrading output quality.
  • stop sequences — catches runaway generation before it fills the output buffer. Triple newline is a reliable signal the model has looped past the end of a coherent response.
  • max_tokens: 512 for chat — enforces conciseness and prevents the model from rambling into a degraded state mid-response.

Context Window Management

The model window is not the binding constraint here — Qwen 3.5 ships a 256K context window, and on a 64GB dual-GPU setup there is ample KV-cache headroom. The truncation caps below exist to protect checkpoint latency (prefill time scales with input length, and the grammar checkpoint must feel responsive), not memory. Documents will almost always fit uncut; the cap only fires on unusually long ones.

Grammar checkpoint (fast, latency-sensitive — keep input modest):

Component Token Budget
System prompt ~300
Document text ~10,000 (hard cap; trailing window)
Response (JSON) ~1,024
Buffer rest of window (ample)

Voice-consistency pass (slow cadence — send the whole document, no trailing-window truncation; see Plagiarism Detection → Tier 1).

Ask Petal chat:

Component Token Budget
System prompt + suggestion context ~600
Conversation history (rolling) ~3,000
Latest user message ~200
Response ~512
Buffer ~3,880

Truncation logic (implement in internal/llm/client.go):

const maxDocChars     = 40000 // ~10000 tokens at ~4 chars/token — grammar checkpoint only
const maxHistoryMsgs  = 10    // 5 turns; drop oldest pairs first

// Document truncation (grammar checkpoint) — keep the recent end (user is actively writing there).
// The cap is a latency guard, not a window limit; the model window (256K) is far larger.
// The voice-consistency pass does NOT apply this truncation — it sends the full content_text.
if len(contentText) > maxDocChars {
    contentText = contentText[len(contentText)-maxDocChars:]
}

// Conversation history truncation — drop oldest messages first
if len(messages) > maxHistoryMsgs {
    messages = messages[len(messages)-maxHistoryMsgs:]
}

Always log a WARN when truncation fires so it's visible in production. Never fail silently.

The Go handler receives the LLM suggestions (which reference original text strings) and records the plain-text offsets via strings.Index(content_text, original) as from_pos / to_pos. These stored offsets are for server-side use only (paragraph extraction for Ask Petal context) and are not ProseMirror positions — see Note #6. The frontend anchors decorations by searching the live document for the original string in ProseMirror coordinates at render time, not by trusting a numeric position. This is the single most important correctness detail in the suggestion pipeline; get it wrong and every multi-block document mis-renders.


Frontend: Tiptap Configuration

Extensions to enable

StarterKit,              // Bold, italic, headings, lists, paragraphs
Underline,
TextAlign,
Placeholder.configure({ placeholder: 'Start writing...' }),
SpellChecker,            // Custom extension wrapping nspell
SuggestionMark,          // Custom extension for AI suggestion decorations
CharacterCount,          // For word count in StatusBar

SuggestionMark Extension

Custom Tiptap mark that:

  • Accepts { suggestionId, type } attributes
  • Anchors by string match, not stored position — when suggestions arrive, walk the live ProseMirror document, find the original text in PM coordinates, and apply the mark over that range. Do not use the server's from_pos/to_pos (those are plain-text offsets, not PM positions — see Note #6). If original isn't found in the current document (already edited), skip that suggestion silently.
  • Renders as a colored underline (color varies by type using CSS vars)
  • On hover, shows <SuggestionCard> positioned absolutely above the text
  • SuggestionCard shows: original → replacement, explanation, Accept button, Dismiss button
  • Accept → calls PUT /api/suggestions/:id with status: accepted, applies replacement via Tiptap command
  • Dismiss → calls PUT /api/suggestions/:id with status: rejected, removes mark

useAutoSave Hook

// Debounce 1500ms after last content change
// Calls PUT /api/docs/:id with { content, content_text, word_count }
// StatusBar shows: "Saving..." → "Saved just now" → fades to nothing after 3s

useCheckpoint Hook

// Debounce 4000ms after last content change
// Fires POST /api/docs/:id/check
// On response: dispatch suggestion decorations to Tiptap editor
// Sets checkpointActive state → StatusBar indicator

Layout

┌─────────────────────────────────────────────────────────────┐
│  [🌸 Petal]    [New Doc]                    [User Avatar]   │  ← Header (48px)
├──────────────┬──────────────────────────────────────────────┤
│              │                                              │
│  Doc List    │         Editor Canvas                        │
│  (260px)     │         (centered, max-width 720px)          │
│              │                                              │
│  [Doc 1]     │    ┌─────────────────────────────┐          │
│  [Doc 2]  ←  │    │  [B] [I] [U] [H1] [H2] [≡] │          │  ← Toolbar
│  [Doc 3]     │    └─────────────────────────────┘          │
│              │                                              │
│  + New       │    Title (editable, large Nunito)            │
│              │                                              │
│              │    Body text in Lora...                      │
│              │    ~~~~~~ suggestion underline ~~~~~~         │
│              │                                              │
│              │                                              │
├──────────────┴──────────────────────────────────────────────┤
│  342 words   ·   ● Checking...   ·   Saved just now         │  ← StatusBar (36px)
└─────────────────────────────────────────────────────────────┘

Distraction-free mode: clicking into the editor collapses the doc list sidebar (slides left), expands editor canvas full width. Click outside canvas or press Escape to restore.


Environment Config

# .env.example

# Server
PORT=8080
BASE_URL=https://write.parodia.dev
SESSION_SECRET=change-me-to-random-64-char-string

# Database
DATABASE_PATH=/data/petal.db

# Authentik OIDC
AUTHENTIK_URL=https://auth.parodia.dev
AUTHENTIK_CLIENT_ID=petal
AUTHENTIK_CLIENT_SECRET=

# LLM
LLM_BACKEND=vllm               # vllm | ollama
LLM_ENDPOINT=http://192.168.x.x:8000
LLM_MODEL=gemma4               # Checkpoint: small and fast (4B7B)
LLM_CHAT_MODEL=qwen3.5:9b     # Ask Petal: Qwen3.5 9B recommended for native Mandarin support
LLM_TIMEOUT=30s

Dockerfile

# Stage 1: Build frontend
FROM node:22-alpine AS frontend
WORKDIR /app/web
COPY web/package*.json ./
RUN npm ci
COPY web/ ./
RUN npm run build

# Stage 2: Build Go binary
FROM golang:1.23-alpine AS backend
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
COPY --from=frontend /app/web/dist ./web/dist
RUN CGO_ENABLED=0 go build -o petal ./cmd/server

# Stage 3: Runtime
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=backend /app/petal .
VOLUME ["/data"]
EXPOSE 8080
CMD ["./petal"]

docker-compose.yml

services:
  petal:
    image: petal:latest
    restart: unless-stopped
    volumes:
      - petal_data:/data
    env_file: .env
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.petal.rule=Host(`write.parodia.dev`)"
      - "traefik.http.routers.petal.entrypoints=web-secure"
      - "traefik.http.routers.petal.tls.certresolver=default"
      - "traefik.http.services.petal.loadbalancer.server.port=8080"
    networks:
      - traefik

volumes:
  petal_data:

networks:
  traefik:
    external: true

v1 Scope (Ship This)

  • Tiptap rich text editor (bold, italic, underline, headings H1/H2, ordered/unordered lists)
  • Document list sidebar (create, rename, delete)
  • Auto-save to SQLite (1.5s debounce)
  • LLM checkpoint every 4s idle → inline suggestion decorations
  • Suggestion hover card (replacement + explanation + accept/dismiss)
  • nspell browser-side spell check (English dictionary)
  • Word count + save status + checkpoint indicator in StatusBar
  • Authentik OIDC auth
  • Plagiarism check (two-tier: local voice consistency + opt-in Copyleaks academic check)
  • Ask Petal conversational follow-up chat on any suggestion
  • Distraction-free mode
  • Soft/bubbly pastel design system
  • Single Docker binary deployment to write.parodia.dev

Out of Scope for v1

  • Export to DOCX / PDF (v1.1)
  • Mobile layout (desktop first)
  • Real-time collaboration
  • Version history / change tracking
  • Multiple language support (English first)
  • Offline mode

Notes for Claude Code

  1. Model names are always config valuesLLM_MODEL drives checkpoint, LLM_CHAT_MODEL drives Ask Petal. Both read from env vars. Never hardcode either. If LLM_CHAT_MODEL is unset, fall back to LLM_MODEL — handle this in the factory, not in individual handlers.

  2. modernc sqlite (modernc.org/sqlite) — pure Go, no cgo, no build friction.

  3. Tiptap JSON format is stored verbatim in documents.content. content_text is the plain text extracted for the LLM — keep these in sync on every save.

  4. nspell dictionaries — load en-US dictionary files from CDN or vendor them into web/public/dictionaries/. Don't shell out to hunspell binary.

  5. Go embeds frontend — use //go:embed web/dist in cmd/server/main.go. Single binary deployment.

  6. Suggestion anchoring — resolve by string, not by stored position. The original text string is the source of truth for where a suggestion belongs, not a stored numeric position. Two reasons:

    • Coordinate mismatch: strings.Index(contentText, original) returns a plain-text character offset. Tiptap/ProseMirror positions are not plain-text offsets — every node boundary (paragraph, heading, list item) consumes position units, so a plaintext offset of N does not equal ProseMirror position N. Handing a plaintext offset to a Tiptap decoration places it in the wrong spot, and the error grows with each block in the document.
    • Staleness: the user keeps typing (auto-save every 1.5s) after a checkpoint fires. Any position captured at checkpoint time is stale by the time the suggestion is stored and rendered.

    Therefore: the frontend resolves each suggestion at render time by searching the live ProseMirror document for the original string (in PM coordinates) and applying the decoration there. The Go handler still runs strings.Index(contentText, original) and stores from_pos/to_pos, but these are plain-text offsets for server-side use only (e.g. extracting the surrounding paragraph for Ask Petal context — see Note #10), never shipped to the client as ProseMirror positions. If original appears multiple times, take the first occurrence; if not found at all, discard the suggestion rather than erroring. On the client, if original is no longer present in the live document (the user already edited that text), silently drop the suggestion — it's obsolete.

  7. Session management — use gorilla/sessions with a cookie store. Session key is petal_session. Store user_id in session after OIDC callback.

  8. Authentik app — needs to be created in Authentik with redirect URI https://write.parodia.dev/api/auth/callback and the OIDC client credentials added to .env.

  9. Ask Petal SSE streaming — use text/event-stream response in Go, flush after each token chunk. Frontend uses fetch with ReadableStream (not EventSource — needs POST) to accumulate tokens into the assistant bubble in real time. Don't buffer the full response.

  10. Ask Petal context injection — the Go handler for /api/suggestions/:id/chat fetches the suggestion + parent document in one query, extracts the paragraph containing from_pos from content_text, and injects all of it into the system prompt before the user messages. Never trust the client to send this context — always load it server-side.

  11. LLM backend abstractionNewLLMClient is the only place that references LLM_BACKEND. Every other package receives an LLMClient interface value. checkpoint.go and the Ask Petal handler must import only the interface, not either concrete type. This keeps adding a third backend (e.g. LM Studio) to a single new file in the future.

  12. Ollama streaming format differs from vLLM — Ollama streams newline-delimited JSON objects ({"message":{"content":"..."},"done":false}), not SSE data: lines. The OllamaClient.Stream() method must handle this format; do not reuse the vLLM SSE parser for Ollama.

  13. Copyleaks async flow — submission returns immediately with a scan ID. Results arrive via webhook, not the submission response. Store the scan ID in plagiarism_reports immediately on submission, update the row when the webhook fires. Frontend polls /api/docs/:id/plagiarism/latest every 5s while status=pending — don't try to make this synchronous.

  14. Copyleaks webhook HMAC — validate the X-Copyleaks-Signature header on every webhook call. Reject without processing if invalid. Never skip this in production.

  15. replacement: null in voice suggestions — the SuggestionCard must check for null replacement and conditionally render. Don't assume replacement is always a string.

  16. CJK font fallback in Ask Petal chat — Nunito has no CJK coverage. The chat bubble font-family must include system CJK fallbacks: 'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif. Apply this specifically to the AskPetal message bubbles, not the editor body.

  17. Mandarin detection is the model's job — do not attempt language detection in Go or the frontend. The prompt instructs the model to detect and match. This works fine with Gemma4 which has solid Mandarin capability. If a future model swap degrades Mandarin quality, that's a prompt/model problem, not an architecture problem.