Files
llmgw/internal/web/chat.go
T
Ilker ManapandClaude Opus 5.5 1b89d5cdea
Release / build (push) Successful in 1m12s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m54s
Say on the chat page whether a GPU or a CPU answered
A model with replicas on a card and on CPUs answered at either speed with no
way to tell which. The picker names where each model runs, each reply names
the hardware that served it, and every answer carries x-llmgw-hardware.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 18:57:50 +02:00

118 lines
3.2 KiB
Go

package web
import (
"net/http"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// ChatModel is one entry in the chat page's model picker.
type ChatModel struct {
Name string
// Simulated marks a model that returns synthetic text rather than
// inference output. The picker says so, because the two are
// indistinguishable from the outside until the reply arrives.
Simulated bool
// OnDemand marks a model with nothing running that loads when asked. It is
// offered anyway: the first message is what asks, so hiding it until it
// runs meant it could never be started from here.
OnDemand bool
// Hardware is what the model's running replicas compute on: "cpu", or a
// GPU class. Several when it runs on both, and empty when nothing runs
// or nothing said.
Hardware []string
}
// Runs names the hardware for the picker: "GPU", "CPU", or "GPU + CPU" for a
// model with replicas on both, where which one answers is the router's choice
// and each reply says which it was.
func (m ChatModel) Runs() string {
var gpu, cpu bool
for _, h := range m.Hardware {
if h == "cpu" {
cpu = true
} else {
gpu = true
}
}
switch {
case gpu && cpu:
return "GPU + CPU"
case gpu:
return "GPU"
case cpu:
return "CPU"
}
return ""
}
// ChatContext is one thing the chat page can spend against: a project, or the
// person themselves.
type ChatContext struct {
Value string
Label string
}
type chatData struct {
Nav Nav
Contexts []ChatContext
Context string
User store.User
Models []ChatModel
CSRF string
Version string
Now string
}
// Chat renders the in-browser chat for one signed-in person.
func Chat(user store.User, models []ChatModel, contexts []ChatContext, current, version string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
noStore(w)
render(w, "chat", chatData{
Nav: navFrom(r, "chat"),
Contexts: contexts, Context: current,
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, p.withNav(r, user), user)
}
}