gui/pkg/display/scheme.go
Snider 750f7d9f43
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Add core network route state
2026-04-15 18:56:12 +01:00

522 lines
18 KiB
Go

package display
import (
"context"
"html"
"net/url"
"sort"
"strings"
"time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/gui/pkg/chat"
"github.com/wailsapp/wails/v3/pkg/application"
)
type SchemeHandler func(context.Context, string, url.Values) core.Result
type assetMiddlewareHandler struct {
next application.Handler
service *Service
}
func (h assetMiddlewareHandler) ServeHTTP(w application.ResponseWriter, r *application.Request) {
rawURL := r.URL
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rawURL)), "core://") {
result := h.service.ResolveScheme(context.Background(), rawURL)
if !result.OK {
w.WriteHeader(404)
_, _ = w.Write([]byte("core route not found"))
return
}
payload, _ := result.Value.(map[string]any)
body, _ := payload["body"].(string)
headers := w.Header()
contentType, _ := payload["content_type"].(string)
if strings.TrimSpace(contentType) == "" {
contentType = "text/html"
}
headers["Content-Type"] = []string{contentType + "; charset=utf-8"}
w.WriteHeader(200)
_, _ = w.Write([]byte(body))
return
}
if h.next != nil {
h.next.ServeHTTP(w, r)
}
}
func (s *Service) HandleScheme(scheme string, handler SchemeHandler) {
if s.schemeHandlers == nil {
s.schemeHandlers = make(map[string]SchemeHandler)
}
s.schemeHandlers[strings.ToLower(strings.TrimSpace(scheme))] = handler
}
func (s *Service) registerDefaultSchemes() {
s.HandleScheme("core", func(ctx context.Context, route string, query url.Values) core.Result {
return s.resolveCoreRoute(ctx, route, query)
})
}
func (s *Service) resolveCoreRoute(ctx context.Context, route string, query url.Values) core.Result {
segment, subpath := splitCoreRoute(route)
if segment == "" {
return core.Result{
Value: coreerr.E("display.resolveCoreRoute", "core route is required", nil),
OK: false,
}
}
switch segment {
case "settings":
return s.resolveSettingsRoute(subpath, query)
case "store":
return s.resolveStoreRoute(subpath, query)
case "network":
return s.resolveNetworkRoute(subpath, query)
case "models":
return s.resolveModelsRoute(subpath, query)
case "chat":
return s.resolveChatRoute(ctx, subpath, query)
case "agent", "wallet", "identity":
return s.resolveUnavailableCoreRoute(segment, subpath, query)
default:
return core.Result{
Value: coreerr.E("display.resolveCoreRoute", "unknown core route: "+segment, nil),
OK: false,
}
}
}
func splitCoreRoute(route string) (string, string) {
route = strings.Trim(strings.TrimSpace(route), "/")
if route == "" {
return "", ""
}
segment, remainder, found := strings.Cut(route, "/")
if !found {
return segment, ""
}
return segment, remainder
}
func (s *Service) resolveSettingsRoute(subpath string, query url.Values) core.Result {
key := coalesce(query.Get("key"), subpath)
snapshot := s.currentSettingsSnapshot()
if key != "" {
value, ok := s.currentSettingValue(key)
if !ok {
return s.resolveUnavailableCoreRoute("settings", subpath, query)
}
return core.Result{
Value: map[string]any{
"content_type": "text/html",
"body": s.renderKeyValuePage(coreRouteURL("settings", key), key, value, snapshot),
"route": "settings",
"key": key,
"value": value,
"settings": snapshot,
},
OK: true,
}
}
return core.Result{
Value: map[string]any{
"content_type": "text/html",
"body": s.renderSettingsPage(snapshot),
"route": "settings",
"settings": snapshot,
},
OK: true,
}
}
func (s *Service) resolveStoreRoute(subpath string, query url.Values) core.Result {
if subpath != "" {
parts := strings.Split(subpath, "/")
if len(parts) >= 2 {
bucket := strings.TrimSpace(parts[0])
key := strings.TrimSpace(strings.Join(parts[1:], "/"))
if entry, ok := s.storage.Get("", bucket, key); ok {
return core.Result{
Value: map[string]any{
"content_type": "text/html",
"body": s.renderStoreEntryPage(entry),
"route": "store",
"entry": entry,
},
OK: true,
}
}
}
}
return s.handleStoreSearch(context.Background(), query)
}
func (s *Service) resolveModelsRoute(subpath string, query url.Values) core.Result {
if modelName := coalesce(query.Get("id"), subpath); modelName != "" {
if model, ok := s.findChatModel(modelName); ok {
return core.Result{
Value: map[string]any{
"content_type": "text/html",
"body": s.renderKeyValuePage(coreRouteURL("models", modelName), modelName, model, s.modelState()),
"route": "models",
"model": model,
},
OK: true,
}
}
return s.resolveUnavailableCoreRoute("models", subpath, query)
}
state := s.modelState()
return core.Result{
Value: map[string]any{
"content_type": "application/json",
"body": core.JSONMarshalString(state),
"state": state,
"models": s.chatModels(),
"route": "models",
},
OK: true,
}
}
func (s *Service) resolveNetworkRoute(subpath string, query url.Values) core.Result {
state := s.networkState()
if interfaceName := coalesce(query.Get("name"), subpath); interfaceName != "" {
for _, iface := range state.Interfaces {
if strings.EqualFold(iface.Name, interfaceName) {
return core.Result{
Value: map[string]any{
"content_type": "text/html",
"body": s.renderNetworkInterfacePage(state, iface),
"route": "network",
"interface": iface,
"state": state,
},
OK: true,
}
}
}
return s.resolveUnavailableCoreRoute("network", subpath, query)
}
return core.Result{
Value: map[string]any{
"content_type": "text/html",
"body": s.renderNetworkPage(state),
"route": "network",
"state": state,
},
OK: true,
}
}
func (s *Service) resolveChatRoute(_ context.Context, subpath string, query url.Values) core.Result {
if id := coalesce(query.Get("conversation_id"), query.Get("id"), subpath); id != "" {
return s.Core().QUERY(chat.QueryHistory{ConversationID: id})
}
return s.Core().QUERY(chat.QueryConversationList{})
}
func (s *Service) resolveUnavailableCoreRoute(route, subpath string, query url.Values) core.Result {
return core.Result{
Value: map[string]any{
"content_type": "text/html",
"body": s.renderUnavailableRoute(route, subpath, query),
"route": route,
"subpath": subpath,
"query": query,
"available": false,
},
OK: true,
}
}
func (s *Service) currentSettingsSnapshot() map[string]any {
if s.configFile != nil {
var snapshot map[string]any
if err := s.configFile.Get("", &snapshot); err == nil && snapshot != nil {
return snapshot
}
}
snapshot := make(map[string]any, len(s.configData))
for key, value := range s.configData {
if value == nil {
continue
}
snapshot[key] = value
}
return snapshot
}
func (s *Service) currentSettingValue(key string) (any, bool) {
if s.configFile != nil {
var value any
if err := s.configFile.Get(key, &value); err == nil {
return value, true
}
}
for section, values := range s.configData {
if strings.Contains(key, ".") {
if nested, ok := values[key]; ok {
return nested, true
}
}
if section == key {
return values, true
}
}
return nil, false
}
func (s *Service) findChatModel(name string) (chat.ModelEntry, bool) {
for _, model := range s.chatModels() {
if strings.EqualFold(model.Name, name) {
return model, true
}
}
return chat.ModelEntry{}, false
}
func (s *Service) ResolveScheme(ctx context.Context, rawURL string) core.Result {
if strings.TrimSpace(rawURL) == "" {
return core.Result{Value: coreerr.E("display.ResolveScheme", "scheme URL is required", nil), OK: false}
}
parsed, err := url.Parse(rawURL)
if err != nil {
return core.Result{Value: err, OK: false}
}
handler, ok := s.schemeHandlers[strings.ToLower(parsed.Scheme)]
if !ok {
return core.Result{Value: coreerr.E("display.ResolveScheme", "no handler registered for scheme "+parsed.Scheme, nil), OK: false}
}
route := strings.Trim(strings.TrimPrefix(parsed.Host+parsed.Path, "/"), "/")
resolved := handler(ctx, route, parsed.Query())
if !resolved.OK {
return resolved
}
if payload, ok := resolved.Value.(map[string]any); ok {
if contentType, _ := payload["content_type"].(string); strings.TrimSpace(contentType) != "" {
if body, ok := payload["body"].(string); ok && strings.TrimSpace(body) != "" {
return core.Result{Value: payload, OK: true}
}
if !strings.EqualFold(contentType, "text/html") {
payload["body"] = core.JSONMarshalString(payload["state"])
return core.Result{Value: payload, OK: true}
}
}
}
body := s.renderSchemeBody(route, resolved.Value)
return core.Result{
Value: map[string]any{
"content_type": "text/html",
"body": body,
"route": route,
"url": rawURL,
},
OK: true,
}
}
func (s *Service) renderSchemeBody(route string, value any) string {
title := "core://" + route
pretty := core.JSONMarshalString(value)
return "<!doctype html><html><head><meta charset=\"utf-8\"><title>" +
html.EscapeString(title) +
"</title><style>body{font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;background:#10171f;color:#edf2f7;margin:0}header{padding:16px 20px;border-bottom:1px solid #243447;background:#111827}main{padding:20px}pre{white-space:pre-wrap;word-break:break-word;background:#0b1220;border:1px solid #243447;border-radius:12px;padding:16px}</style></head><body><header><strong>" +
html.EscapeString(title) +
"</strong></header><main><pre>" +
html.EscapeString(pretty) +
"</pre></main></body></html>"
}
func (s *Service) renderSettingsPage(settings map[string]any) string {
safeSettings := core.JSONMarshalString(settings)
return "<!doctype html><html><head><meta charset=\"utf-8\"><title>core://settings</title><style>body{font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;background:#08111d;color:#e2e8f0;margin:0}header{padding:20px;border-bottom:1px solid #1f2a37;background:linear-gradient(180deg,#0f172a,#08111d)}main{padding:20px;display:grid;gap:16px}section{background:#0b1220;border:1px solid #1f2a37;border-radius:16px;padding:16px}pre{margin:0;white-space:pre-wrap;word-break:break-word}</style></head><body><header><strong>core://settings</strong><div class=\"meta\">Application settings and live config state.</div></header><main><section><pre>" +
html.EscapeString(safeSettings) +
"</pre></section></main></body></html>"
}
func (s *Service) renderKeyValuePage(title, key string, value any, snapshot any) string {
return "<!doctype html><html><head><meta charset=\"utf-8\"><title>" +
html.EscapeString(title) +
"</title><style>body{font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;background:#08111d;color:#e2e8f0;margin:0}header{padding:20px;border-bottom:1px solid #1f2a37;background:linear-gradient(180deg,#0f172a,#08111d)}main{padding:20px;display:grid;gap:16px}section{background:#0b1220;border:1px solid #1f2a37;border-radius:16px;padding:16px}pre{margin:0;white-space:pre-wrap;word-break:break-word}code{background:#111827;border-radius:8px;padding:2px 6px}</style></head><body><header><strong>" +
html.EscapeString(title) +
"</strong></header><main><section><div>Key: <code>" +
html.EscapeString(key) +
"</code></div><pre>" +
html.EscapeString(core.JSONMarshalString(value)) +
"</pre></section><section><pre>" +
html.EscapeString(core.JSONMarshalString(snapshot)) +
"</pre></section></main></body></html>"
}
func (s *Service) renderStoreEntryPage(entry StorageEntry) string {
return "<!doctype html><html><head><meta charset=\"utf-8\"><title>core://store</title><style>body{font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;background:#0f172a;color:#e2e8f0;margin:0}header{padding:20px;border-bottom:1px solid #1e293b;background:linear-gradient(180deg,#111827,#0f172a)}main{padding:20px;display:grid;gap:16px}section{background:#020617;border:1px solid #1e293b;border-radius:16px;padding:16px}code,pre{background:#111827;border-radius:8px;padding:2px 6px}pre{white-space:pre-wrap;word-break:break-word;padding:12px}</style></head><body><header><strong>core://store</strong></header><main><section><div><strong>Origin:</strong> " +
html.EscapeString(entry.Origin) +
"</div><div><strong>Bucket:</strong> " +
html.EscapeString(entry.Bucket) +
"</div><div><strong>Key:</strong> " +
html.EscapeString(entry.Key) +
"</div><div><strong>Updated:</strong> " +
html.EscapeString(entry.UpdatedAt.Format(time.RFC3339)) +
"</div><pre>" +
html.EscapeString(entry.Value) +
"</pre></section></main></body></html>"
}
func (s *Service) renderUnavailableRoute(route, subpath string, query url.Values) string {
body := map[string]any{
"available": false,
"route": route,
"subpath": subpath,
"query": query,
"reason": "no backend is registered for this route",
}
return s.renderSchemeBody(route, body)
}
func (s *Service) renderStoreSearchPage(query string, results []StorageEntry) string {
safeQuery := html.EscapeString(query)
type groupedResults struct {
Origin string
Entries []StorageEntry
UpdatedAt time.Time
}
groupMap := make(map[string]*groupedResults)
for _, item := range results {
group := groupMap[item.Origin]
if group == nil {
group = &groupedResults{Origin: item.Origin}
groupMap[item.Origin] = group
}
group.Entries = append(group.Entries, item)
if item.UpdatedAt.After(group.UpdatedAt) {
group.UpdatedAt = item.UpdatedAt
}
}
groups := make([]groupedResults, 0, len(groupMap))
for _, group := range groupMap {
groups = append(groups, *group)
}
sort.Slice(groups, func(i, j int) bool {
return groups[i].UpdatedAt.After(groups[j].UpdatedAt)
})
for i := range groups {
sort.Slice(groups[i].Entries, func(a, b int) bool {
return groups[i].Entries[a].UpdatedAt.After(groups[i].Entries[b].UpdatedAt)
})
}
var items strings.Builder
if len(results) == 0 && strings.TrimSpace(query) != "" {
items.WriteString("<p class=\"empty\">No matches found in Core storage.</p>")
} else if strings.TrimSpace(query) == "" {
items.WriteString("<p class=\"meta\">Enter a search term to scan Core storage namespaces.</p>")
} else {
for _, group := range groups {
items.WriteString("<section class=\"origin-group\"><div class=\"origin\">")
items.WriteString(html.EscapeString(group.Origin))
items.WriteString("</div><ul>")
for _, item := range group.Entries {
items.WriteString("<li class=\"result\"><div class=\"bucket\">")
items.WriteString(html.EscapeString(item.Bucket))
items.WriteString("</div><div class=\"key\">")
items.WriteString(html.EscapeString(item.Key))
items.WriteString("</div><div class=\"value\">")
items.WriteString(html.EscapeString(item.Value))
items.WriteString("</div><div class=\"meta\">Updated ")
items.WriteString(html.EscapeString(item.UpdatedAt.Format(time.RFC3339)))
items.WriteString("</div></li>")
}
items.WriteString("</ul></section>")
}
}
return "<!doctype html><html><head><meta charset=\"utf-8\"><title>core://store</title><style>body{font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;background:#0f172a;color:#e2e8f0;margin:0}header{padding:20px;border-bottom:1px solid #1e293b;background:linear-gradient(180deg,#111827,#0f172a)}main{padding:20px;display:grid;gap:16px}form{display:flex;gap:8px;flex-wrap:wrap;align-items:center}input{min-width:min(100%,420px);flex:1 1 320px;border-radius:12px;border:1px solid #334155;background:#020617;color:#e2e8f0;padding:12px 14px}button{border:0;border-radius:12px;background:#38bdf8;color:#082f49;padding:12px 16px;font-weight:700;cursor:pointer}section{background:#020617;border:1px solid #1e293b;border-radius:16px;padding:16px}.origin-group{display:grid;gap:12px}.origin-group .origin{font-weight:700;color:#7dd3fc}.origin-group ul{list-style:none;padding:0;margin:0;display:grid;gap:12px}.result{padding:12px;border:1px solid #1e293b;border-radius:12px;background:#0b1220;display:grid;gap:8px}.meta{color:#94a3b8}.bucket{color:#cbd5e1;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.key{color:#f8fafc;font-weight:600}.value{white-space:pre-wrap;word-break:break-word;color:#e2e8f0}.empty{color:#94a3b8}code{background:#111827;border-radius:8px;padding:2px 6px}</style></head><body><header><strong>core://store</strong><div class=\"meta\">Search the in-memory storage scopes exposed by the preload shim. Query: <code>" +
safeQuery +
"</code></div></header><main><section><form method=\"get\" action=\"core://store\"><input name=\"q\" value=\"" +
safeQuery +
"\" placeholder=\"Search keys or values\"><button type=\"submit\">Search</button></form></section><section><div id=\"results\">" + items.String() + "</div></section></main></body></html>"
}
func (s *Service) searchAllStorage(query string) []StorageEntry {
results := s.storage.Search(query)
if conversations := s.Core().QUERY(chat.QueryConversationSearch{Query: query}); conversations.OK {
switch list := conversations.Value.(type) {
case []any:
for _, item := range list {
results = append(results, StorageEntry{
Origin: "core://chat",
Bucket: "conversation",
Key: "summary",
Value: core.JSONMarshalString(item),
UpdatedAt: time.Now(),
})
}
default:
if payload := core.JSONMarshalString(list); payload != "null" && payload != "" && payload != "[]" {
results = append(results, StorageEntry{
Origin: "core://chat",
Bucket: "conversation",
Key: "summary",
Value: payload,
UpdatedAt: time.Now(),
})
}
}
}
return results
}
func (s *Service) handleStoreSearch(_ context.Context, params url.Values) core.Result {
query := coalesce(params.Get("q"), params.Get("query"))
results := s.searchAllStorage(query)
return core.Result{
Value: map[string]any{
"content_type": "text/html",
"body": s.renderStoreSearchPage(query, results),
"route": "store",
"url": "core://store",
"query": params,
"results": results,
},
OK: true,
}
}
func coalesce(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func coreRouteURL(segment string, parts ...string) string {
route := "core://" + strings.Trim(strings.TrimSpace(segment), "/")
for _, part := range parts {
if strings.TrimSpace(part) == "" {
continue
}
route += "/" + url.PathEscape(part)
}
return route
}
func (s *Service) AssetMiddleware() application.Middleware {
return func(next application.Handler) application.Handler {
return assetMiddlewareHandler{service: s, next: next}
}
}