188 lines
5.4 KiB
Go
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)
|
|
}
|