Files
petal/cmd/server/main.go
prosolis 60eba25fee Phase 9: ESL superpowers — Chinese gloss + tone-rewrite
Inline Chinese gloss (offline) and a "say it more naturally" / tone-rewrite,
the two ESL features for the Mandarin-speaking writer.

Gloss: embedded English→Chinese dictionary (gloss.json.gz, 57k common words
built from ECDICT via scripts/build_gloss.py). lexicon gains Gloss()/Result.Gloss
and a lightweight GET /api/gloss/{word}; the right-click WordCard leads with the
中文; GlossTip shows it on a 350ms hover (reuses wordAt, so CJK is never glossed).
Offline + instant, works with the LLM down.

Rewrite: selecting text pops a SelectionBubble (更自然 + the tone vocabulary);
picking a style calls POST /api/docs/:id/rewrite (llm.RunRewrite, stateless,
owner-scoped) and shows a RewritePreview (original→rewrite, accept/cancel/retry).
Accept applies it in-editor.

Tests added in lexicon and suggestions. go build/vet/test, tsc, vite all clean;
live smoke vs a fake vLLM verified gloss + rewrite + 400/404/502 paths.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 00:07:26 -07:00

138 lines
4.4 KiB
Go

package main
import (
"crypto/sha256"
"encoding/hex"
"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/lexicon"
"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)
// Build version: a hash of the embedded SPA shell. Vite rewrites index.html
// with content-hashed asset names on every build, so this string changes
// exactly when a new frontend is deployed — the client polls it to know when
// to offer a refresh.
version := buildVersion()
log.Printf("frontend build version %s", version)
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"}`))
})
api.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Never cache: a stale cached version would defeat the whole check.
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(`{"version":"` + version + `"}`))
})
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())
// Offline lexicon: full word lookups (gloss + definition + synonyms) for
// the right-click popover, and the lightweight Chinese-only gloss for the
// inline hover/select tooltip. One handler so the datasets load once.
lex := lexicon.NewHandler()
api.Mount("/word", lex.Routes())
api.Mount("/gloss", lex.GlossRoutes())
})
// 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)
}
}
// buildVersion derives a short, stable identifier for the currently embedded
// frontend by hashing dist/index.html. Vite stamps content-hashed asset names
// into that file each build, so the digest is a reliable "did the deploy
// change?" signal. Falls back to "dev" when the frontend hasn't been built.
func buildVersion() string {
data, err := fs.ReadFile(web.DistFS, "dist/index.html")
if err != nil {
return "dev"
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])[:12]
}
// 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)
}
}