Files
enkly-ai/services/gateway/internal/handlers/ui.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

188 lines
5.4 KiB
Go

// Package handlers (ui.go) replaces internal/handlers/ui.go: the theme
// toggle, the lazy-expanding file explorer tree, and the status bar.
// Where the original read the local filesystem directly, this calls
// workspace's Tree RPC (one_level=true for a single folder's children,
// matching the original's per-click round trip exactly).
package handlers
import (
"context"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
conversationv1 "enkly/gen/conversation/v1"
workspacev1 "enkly/gen/workspace/v1"
)
const themeCookieName = "enkly_theme"
func themeFrom(r *http.Request) string {
c, err := r.Cookie(themeCookieName)
if err != nil {
return ""
}
if c.Value == "light" || c.Value == "dark" {
return c.Value
}
return ""
}
func safeNext(next string) string {
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.ContainsAny(next, "\\\r\n") {
return "/"
}
return next
}
func (h *Handlers) SetTheme(w http.ResponseWriter, r *http.Request) {
switch r.PostFormValue("theme") {
case "light":
h.setCookie(w, themeCookieName, "light", int((365 * 24 * time.Hour).Seconds()))
case "dark":
h.setCookie(w, themeCookieName, "dark", int((365 * 24 * time.Hour).Seconds()))
case "auto":
h.setCookie(w, themeCookieName, "", -1)
default:
http.Error(w, "unknown theme", http.StatusBadRequest)
return
}
http.Redirect(w, r, safeNext(r.PostFormValue("next")), http.StatusSeeOther)
}
// ---------------------------------------------------------------------
// File explorer (lazy-expanding tree) — replaces Handlers.FileTree.
// ---------------------------------------------------------------------
type treeNode struct {
Name string
Path string
IsDir bool
Open bool
Children []treeNode
ExpandURL string
CollapseURL string
DownloadURL string
SizeHuman string
}
func treeURL(rel string, open bool) string {
o := "1"
if !open {
o = "0"
}
return "/tree?path=" + url.QueryEscape(rel) + "&open=" + o
}
// downloadURLFor replaces handlers.downloadURLFor: percent-encodes each
// path segment individually (so a filename with spaces, '#', '?', or
// non-ASCII characters still produces a valid, correctly round-tripping
// link) while leaving the '/' separators between segments alone.
func downloadURLFor(rel string) string {
segments := strings.Split(rel, "/")
for i, s := range segments {
segments[i] = url.PathEscape(s)
}
return "/files/download/" + strings.Join(segments, "/")
}
// humanBytes replaces handlers.humanBytes.
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return strconv.FormatInt(n, 10) + " B"
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return strconv.FormatFloat(float64(n)/float64(div), 'f', 1, 64) + " " + "KMGTPE"[exp:exp+1] + "iB"
}
func (h *Handlers) FileTree(w http.ResponseWriter, r *http.Request) {
rel := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
resp, err := h.Workspace.Tree(r.Context(), &workspacev1.TreeRequest{Path: rel, OneLevel: true})
if err != nil {
http.NotFound(w, r)
return
}
kids := make([]treeNode, 0, len(resp.GetNodes()))
for _, n := range resp.GetNodes() {
tn := treeNode{Name: n.GetName(), Path: n.GetRelPath(), IsDir: n.GetIsDir()}
if n.GetIsDir() {
tn.ExpandURL = treeURL(n.GetRelPath(), true)
} else {
tn.DownloadURL = downloadURLFor(n.GetRelPath())
tn.SizeHuman = humanBytes(n.GetSize())
}
kids = append(kids, tn)
}
if rel == "" {
h.render(w, http.StatusOK, "tree_root", treeNode{Children: kids})
return
}
node := treeNode{
Name: path.Base(rel), Path: rel, IsDir: true,
Open: r.URL.Query().Get("open") != "0",
ExpandURL: treeURL(rel, true),
CollapseURL: treeURL(rel, false),
}
if node.Open {
node.Children = kids
}
h.render(w, http.StatusOK, "tree_dir", node)
}
// ---------------------------------------------------------------------
// Status bar — replaces Handlers.StatusBar.
// ---------------------------------------------------------------------
type statusData struct {
ConversationID int64
Model string
PostgresOK bool
VLLMOK bool
ContextTokens int
ContextLimit int
ContextPct int
}
func estimateTokens(chars int) int { return (chars + 3) / 4 }
func (h *Handlers) StatusBar(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
convID, _ := strconv.ParseInt(r.URL.Query().Get("conversation_id"), 10, 64)
//data := statusData{ConversationID: convID, Model: "qwen3.5"}
data := statusData{ConversationID: convID, Model: "Baobab 1"}
// PostgresOK now stands in for "the conversation service (and
// hence its Postgres) is reachable" — the gateway itself no longer
// holds a DB handle to ping directly.
if _, err := h.Conversation.CountMessages(ctx, &conversationv1.CountMessagesRequest{ConversationId: convID}); err == nil {
data.PostgresOK = true
}
if convID != 0 && user != nil {
if msgs, err := h.Conversation.ListMessages(ctx, &conversationv1.ListMessagesRequest{ConversationId: convID}); err == nil {
total := 0
for _, m := range msgs.GetMessages() {
total += len(m.GetContent())
}
data.ContextTokens = estimateTokens(total)
}
}
data.VLLMOK = true // see ARCHITECTURE.md: the gateway has no direct vLLM reachability check post-split; agent/knowledge each probe their own chat/embed servers.
h.render(w, http.StatusOK, "status_bar", data)
}