Files
llmgw/internal/web/chat.go
T
Ilker ManapandClaude Opus 5.5 5ee8e74de1 The portal's chat answers from the documents a person picks
A Documents picker on /chat offers the collections the person may read in
the chosen project, the default when there is one, or none; the sources an
answer was given are listed under it. The page's choice becomes the
X-Llmgw-Knowledge header; one the page sent itself is dropped. In English
and Turkish.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:59:23 +02:00

135 lines
3.8 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
}
// ChatCollection is a knowledge-base collection the chat can answer from.
type ChatCollection struct {
ID, Label string
}
type chatData struct {
Nav Nav
Contexts []ChatContext
Context string
// Collections are what this person may read in the chosen context;
// KnowledgeDefault names the ones its part of the tree answers from
// when none is chosen, if any.
Collections []ChatCollection
KnowledgeDefault string
User store.User
Models []ChatModel
CSRF string
Version string
Now string
}
// ChatKnowledge is what the chat page offers to answer from.
type ChatKnowledge struct {
Collections []ChatCollection
Default string
}
// Chat renders the in-browser chat for one signed-in person.
func Chat(user store.User, models []ChatModel, contexts []ChatContext, current, version string, kb ChatKnowledge) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
noStore(w)
render(w, r, "chat", chatData{
Nav: navFrom(r, "chat"),
Contexts: contexts, Context: current,
Collections: kb.Collections, KnowledgeDefault: kb.Default,
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)
}
}