Files
llmgw/internal/web/chat.go
T
Ilker ManapandClaude Opus 5 de21649ad6 Add an in-browser chat, without putting a key in the browser
So someone can try a model without setting up a client, and — more often —
tell whether a problem is the gateway or their code.

The page carries a session, and the server turns that into the identity an API
key would have produced, resolved from the person's project. The request then
goes down the ordinary path by being handed to the same handler /v1 uses:
admission, model allowlist, rate limits, budget, accounting. There is exactly
one request path, so there is no second place for its rules to drift — and
without that, a playground is a hole in every limit the rest of the gateway
enforces. The allowlist test posts a forbidden model and expects 403 for that
reason.

The body is rebuilt rather than forwarded, so the page cannot set fields it has
no business setting: a stream:false that would hang the reader, a max_tokens
large enough to occupy a replica for an hour. One turn is capped at 512 tokens.

The picker offers what is actually served and allowed, not what is catalogued,
because offering a model with no replica produces a 503 the reader cannot
interpret.

Inline vanilla JavaScript, for the same reason the charts are hand-drawn SVG.
It streams, because on CPU inference ten seconds is normal and a chat that
hangs and then dumps a paragraph feels broken. Replies are written with
textContent throughout: model output and user input have no business being
parsed as markup. Its POSTs carry the CSRF token in a header, which a
cross-site form cannot set at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:05:30 +02:00

66 lines
1.7 KiB
Go

package web
import (
"net/http"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
type chatData struct {
User store.User
Models []string
CSRF string
Version string
Now string
}
// Chat renders the in-browser chat for one signed-in person.
func Chat(user store.User, models []string, version string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
noStore(w)
render(w, "chat", chatData{
User: user, Models: models, CSRF: csrfFor(r),
Version: version, Now: time.Now().UTC().Format(time.RFC3339),
})
}
}
// Guard runs a handler only for a signed-in, approved account.
//
// Exported because the chat lives in the router — it needs the endpoint
// registry and the request path — while the session machinery lives here. The
// alternative was a second copy of the session check next to the one thing that
// must never have two copies.
//
// Its POSTs carry the CSRF token in a header rather than a form field, because
// they are JSON. A header is a stronger check than a field: a cross-site form
// post cannot set one at all.
func (p *AccountPages) Guard(
next func(w http.ResponseWriter, r *http.Request, user store.User),
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
noStore(w)
sess, user, ok := p.session(r)
if !ok {
if r.Method == http.MethodGet {
http.Redirect(w, r, "/login", http.StatusSeeOther)
} else {
http.Error(w, "not signed in", http.StatusUnauthorized)
}
return
}
if r.Method != http.MethodGet {
token := r.Header.Get("X-CSRF-Token")
if token == "" {
token = r.FormValue("csrf")
}
if !p.accounts.CheckCSRF(sess, token) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
}
next(w, r, user)
}
}