Files
petal/cmd/server/main.go
prosolis a4069d5755 Phase 3: LLM grammar checkpoint
Backend (internal/llm): backend-agnostic LLMClient interface + factory
with vLLM (OpenAI-compat) and Ollama (native) clients, each Complete +
Stream. prompts.go holds the checkpoint and Ask Petal templates;
checkpoint.go salvages JSON from model output (brace-matched), enforces a
per-doc 30s RateLimiter, and truncates the doc to a latency cap.

internal/suggestions: POST /api/docs/:id/check runs a checkpoint and
replaces the doc's pending suggestions in one tx (accepted/rejected kept
as history); GET /api/docs/:id/suggestions lists pending;
POST /api/suggestions/:id/{accept,dismiss} resolves one. Throttled checks
return the current set rather than erroring.

Frontend: useCheckpoint (4s debounce, loads existing on open, stale-guard
tokens); SuggestionHighlight renders ProseMirror decorations re-anchored
by the `original` string on every doc change (not stored marks), with
precise textblock-offset→PM-position mapping; SuggestionCard shows the
type tag + diff + explanation and applies the replacement in-editor on
accept; breathing rose checkpoint dot in the StatusBar; fade-float +
breathe animations.

Tests: llm parse/rate-limit/truncate; suggestions full flow + rate-limit
over httptest with a stub client. Smoke-tested end-to-end against a fake
vLLM endpoint (anchoring verified) and the LLM-unreachable 502 path.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 20:45:30 -07:00

101 lines
2.9 KiB
Go

package main
import (
"errors"
"io/fs"
"log"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"gitea.parodia.dev/drwily/petal/internal/config"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/docs"
"gitea.parodia.dev/drwily/petal/internal/llm"
"gitea.parodia.dev/drwily/petal/internal/suggestions"
"gitea.parodia.dev/drwily/petal/web"
)
func main() {
cfg := config.Load()
database, err := db.Open(cfg.DatabasePath)
if err != nil {
log.Fatalf("database: %v", err)
}
defer database.Close()
log.Printf("database ready at %s", cfg.DatabasePath)
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Route("/api", func(api chi.Router) {
api.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
llmClient := llm.NewLLMClient(cfg)
sug := suggestions.New(database, llmClient)
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
// both under /api/docs.
docsRouter := docs.New(database).Routes()
sug.RegisterDocRoutes(docsRouter)
api.Mount("/docs", docsRouter)
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
api.Mount("/suggestions", sug.Routes())
})
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
r.NotFound(spaHandler())
addr := ":" + cfg.Port
log.Printf("petal listening on %s (LLM backend=%s)", addr, cfg.LLMBackend)
if err := http.ListenAndServe(addr, r); err != nil {
log.Fatalf("server error: %v", err)
}
}
// spaHandler serves the embedded web/dist as a single-page app: static files
// when they exist, falling back to index.html for unknown paths. If the
// frontend hasn't been built yet, it returns a friendly dev hint instead.
func spaHandler() http.HandlerFunc {
sub, err := fs.Sub(web.DistFS, "dist")
if err != nil {
log.Fatalf("embed sub: %v", err)
}
if _, err := fs.Stat(sub, "index.html"); errors.Is(err, fs.ErrNotExist) {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("Petal backend is running, but the frontend isn't built yet.\n" +
"Run `npm run build` in web/, or use `npm run dev` for the dev server on :5173.\n"))
}
}
fileServer := http.FileServer(http.FS(sub))
return func(w http.ResponseWriter, req *http.Request) {
p := strings.TrimPrefix(req.URL.Path, "/")
if p == "" {
p = "index.html"
}
if _, err := fs.Stat(sub, p); errors.Is(err, fs.ErrNotExist) {
// Unknown path → let the SPA router handle it.
req2 := new(http.Request)
*req2 = *req
req2.URL.Path = "/"
fileServer.ServeHTTP(w, req2)
return
}
fileServer.ServeHTTP(w, req)
}
}