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
77 lines
2.2 KiB
Go
77 lines
2.2 KiB
Go
package suggestions
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
|
)
|
|
|
|
// rewriteRequest is the body the selection bubble posts: the selected passage
|
|
// and the target style ("natural", "academic", …). The text is the client's
|
|
// live selection — unlike a checkpoint, there is nothing to anchor server-side,
|
|
// so the rewrite is stateless and never persisted (the editor applies it
|
|
// directly, and the version history captures the resulting document change).
|
|
type rewriteRequest struct {
|
|
Text string `json:"text"`
|
|
Style string `json:"style"`
|
|
}
|
|
|
|
type rewriteResponse struct {
|
|
Rewrite string `json:"rewrite"`
|
|
}
|
|
|
|
// rewrite runs a one-shot tone-rewrite over a selected passage and returns the
|
|
// rewritten text for the editor to preview. The document id scopes the request
|
|
// to the owner (and reserves room to feed document context to the model later),
|
|
// even though the passage itself rides in the body.
|
|
func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
|
|
docID := chi.URLParam(r, "id")
|
|
|
|
var body rewriteRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
errorJSON(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
text := strings.TrimSpace(body.Text)
|
|
if text == "" {
|
|
errorJSON(w, http.StatusBadRequest, "no text to rewrite")
|
|
return
|
|
}
|
|
if len([]rune(text)) > llm.RewriteMaxRunes {
|
|
errorJSON(w, http.StatusBadRequest, "selection too long to rewrite")
|
|
return
|
|
}
|
|
|
|
// Scope to the owner so a stray id can't drive rewrites against another
|
|
// user's document (and 404 cleanly when it doesn't exist).
|
|
var exists int
|
|
err := h.DB.QueryRow(
|
|
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
|
|
docID, db.LocalUserID,
|
|
).Scan(&exists)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
errorJSON(w, http.StatusNotFound, "document not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
|
|
out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style)
|
|
if err != nil {
|
|
errorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, rewriteResponse{Rewrite: out})
|
|
}
|