From 2343f2522aee9e7ddc468840bcf4bd68dfcefb85 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 15 Apr 2026 19:47:12 +0100 Subject: [PATCH] Implement missing GUI RFC contracts --- pkg/chat/messages.go | 2 + pkg/chat/service.go | 5 ++ pkg/chat/service_test.go | 15 +++++ pkg/display/events.go | 9 +-- pkg/display/events_test.go | 8 +++ pkg/display/hlcrf.go | 18 +++++ pkg/display/hlcrf_test.go | 7 ++ pkg/display/scheme.go | 63 ++++++++++++++++- pkg/display/scheme_test.go | 28 ++++++++ pkg/display/sidecar.go | 18 +++-- pkg/marketplace/marketplace.go | 78 ++++++++++++++++++++++ pkg/marketplace/marketplace_test.go | 42 ++++++++++++ ui/src/chat/chat-state.service.ts | 35 +++++----- ui/src/chat/settings-panel.component.ts | 2 +- ui/src/generated/core-gui-chat.bindings.ts | 1 + 15 files changed, 303 insertions(+), 28 deletions(-) diff --git a/pkg/chat/messages.go b/pkg/chat/messages.go index 64850401..f0b23294 100644 --- a/pkg/chat/messages.go +++ b/pkg/chat/messages.go @@ -11,6 +11,8 @@ type QueryModels struct{} type QuerySettings struct{} +type QuerySettingsDefaults struct{} + type QueryConversationList struct{} type QueryConversationGet struct { diff --git a/pkg/chat/service.go b/pkg/chat/service.go index 75998e04..8429ce66 100644 --- a/pkg/chat/service.go +++ b/pkg/chat/service.go @@ -202,6 +202,8 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { return core.Result{Value: s.discoverModels(), OK: true} case QuerySettings: return core.Result{Value: s.loadSettings(), OK: true} + case QuerySettingsDefaults: + return core.Result{Value: DefaultSettings(), OK: true} case QueryConversationList: conversations, err := s.listConversationSummaries() return core.Result{}.New(conversations, err) @@ -264,6 +266,9 @@ func (s *Service) registerActions() { c.Action("gui.chat.settings.load", func(_ context.Context, _ core.Options) core.Result { return core.Result{Value: s.loadSettings(), OK: true} }) + c.Action("gui.chat.settings.defaults", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: DefaultSettings(), OK: true} + }) c.Action("gui.chat.settings.reset", func(_ context.Context, _ core.Options) core.Result { settings := DefaultSettings() err := s.saveSettings(settings) diff --git a/pkg/chat/service_test.go b/pkg/chat/service_test.go index 8f8e9c97..781e3d8f 100644 --- a/pkg/chat/service_test.go +++ b/pkg/chat/service_test.go @@ -140,3 +140,18 @@ func TestService_Good_SelectModelUpdatesConversation(t *testing.T) { require.True(t, updated.OK) assert.Equal(t, "lemma", updated.Value.(Conversation).Model) } + +func TestService_Good_SettingsDefaults(t *testing.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: [DONE]\n\n") + }, &mockToolExecutor{}) + + result := c.QUERY(QuerySettingsDefaults{}) + require.True(t, result.OK) + assert.Equal(t, DefaultSettings(), result.Value.(ChatSettings)) + + actionResult := c.Action("gui.chat.settings.defaults").Run(context.Background(), core.Options{}) + require.True(t, actionResult.OK) + assert.Equal(t, DefaultSettings(), actionResult.Value.(ChatSettings)) +} diff --git a/pkg/display/events.go b/pkg/display/events.go index 29483aa5..7c1dbe47 100644 --- a/pkg/display/events.go +++ b/pkg/display/events.go @@ -292,7 +292,6 @@ func (em *WSEventManager) handleMessages(conn *websocket.Conn) { return } - handled := true switch msg.Action { case "subscribe": em.subscribe(conn, msg.ID, msg.EventTypes) @@ -301,9 +300,6 @@ func (em *WSEventManager) handleMessages(conn *websocket.Conn) { case "list": em.listSubscriptions(conn) default: - handled = false - } - if !handled { em.closeWithPolicyViolation(conn, "unknown websocket action") return } @@ -319,7 +315,12 @@ func (em *WSEventManager) closeWithPolicyViolation(conn *websocket.Conn, reason } state.writeMu.Lock() defer state.writeMu.Unlock() + _ = conn.WriteJSON(map[string]any{ + "error": reason, + "status": websocket.ClosePolicyViolation, + }) _ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, reason), time.Now().Add(2*time.Second)) + _ = conn.Close() } // subscribe adds a subscription for a client. diff --git a/pkg/display/events_test.go b/pkg/display/events_test.go index e4dfe6d2..54e729db 100644 --- a/pkg/display/events_test.go +++ b/pkg/display/events_test.go @@ -173,6 +173,10 @@ func TestWSEventManager_HandleWebSocket_ClosesOnMalformedMessage(t *testing.T) { require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"action":`))) + payload := readJSONMessage(t, conn) + assert.Equal(t, "invalid websocket message", payload["error"]) + assert.Equal(t, float64(websocket.ClosePolicyViolation), payload["status"]) + _, _, err := conn.ReadMessage() require.Error(t, err) } @@ -184,6 +188,10 @@ func TestWSEventManager_HandleWebSocket_ClosesOnUnknownAction(t *testing.T) { require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"action":"bogus"}`))) + payload := readJSONMessage(t, conn) + assert.Equal(t, "unknown websocket action", payload["error"]) + assert.Equal(t, float64(websocket.ClosePolicyViolation), payload["status"]) + _, _, err := conn.ReadMessage() require.Error(t, err) } diff --git a/pkg/display/hlcrf.go b/pkg/display/hlcrf.go index 9457d19e..4cea3ce7 100644 --- a/pkg/display/hlcrf.go +++ b/pkg/display/hlcrf.go @@ -3,9 +3,12 @@ package display import ( "os" "path/filepath" + "regexp" "strings" ) +var hlcrfSlotPattern = regexp.MustCompile(`\{\{\s*slot\s+"([^"]+)"\s*\}\}`) + func (s *Service) buildHLCRFComponents(pageURL string) string { loaded, err := s.loadManifestForOrigin(pageURL) if err != nil || loaded == nil { @@ -33,6 +36,7 @@ func (s *Service) buildHLCRFComponents(pageURL string) string { } func renderHLCRFComponent(tag, templateBody string) string { + templateBody = compileHLCRFTemplate(templateBody) return `(function(){if(customElements.get(` + quoteJS(tag) + `)){return;}const tpl=document.createElement('template');tpl.innerHTML=` + quoteJS(templateBody) + `;class CoreHLCRFElement extends HTMLElement{connectedCallback(){if(this.shadowRoot){return;}const root=this.attachShadow({mode:'open'});root.appendChild(tpl.content.cloneNode(true));}}customElements.define(` + @@ -40,6 +44,20 @@ func renderHLCRFComponent(tag, templateBody string) string { `,CoreHLCRFElement);})();` } +func compileHLCRFTemplate(templateBody string) string { + return hlcrfSlotPattern.ReplaceAllStringFunc(templateBody, func(source string) string { + match := hlcrfSlotPattern.FindStringSubmatch(source) + if len(match) < 2 { + return source + } + slotName := strings.TrimSpace(match[1]) + if slotName == "" || strings.EqualFold(slotName, "default") { + return "" + } + return `` + }) +} + func defaultHLCRFTag(name string) string { name = strings.TrimSpace(strings.ToLower(name)) name = strings.TrimSuffix(name, filepath.Ext(name)) diff --git a/pkg/display/hlcrf_test.go b/pkg/display/hlcrf_test.go index a846e371..a41ee538 100644 --- a/pkg/display/hlcrf_test.go +++ b/pkg/display/hlcrf_test.go @@ -46,6 +46,13 @@ func TestHLCRF_BuildHLCRFComponents_Good(t *testing.T) { assert.Contains(t, script, "core-inline") } +func TestHLCRF_CompileHLCRFTemplate_Good(t *testing.T) { + compiled := compileHLCRFTemplate(`
{{slot "H"}}
{{ slot "L-C" }}
`) + + assert.Contains(t, compiled, ``) + assert.Contains(t, compiled, ``) +} + func TestHLCRF_BuildHLCRFComponents_Bad(t *testing.T) { svc := &Service{} diff --git a/pkg/display/scheme.go b/pkg/display/scheme.go index 2cec268f..54acc8d3 100644 --- a/pkg/display/scheme.go +++ b/pkg/display/scheme.go @@ -80,8 +80,12 @@ func (s *Service) resolveCoreRoute(ctx context.Context, route string, query url. return s.resolveModelsRoute(subpath, query) case "chat": return s.resolveChatRoute(ctx, subpath, query) - case "agent", "wallet", "identity": - return s.resolveUnavailableCoreRoute(segment, subpath, query) + case "agent": + return s.resolveServiceBackedCoreRoute("agent", subpath, query, "agent", "core-agent") + case "wallet": + return s.resolveServiceBackedCoreRoute("wallet", subpath, query, "wallet", "blockchain", "go-blockchain") + case "identity": + return s.resolveServiceBackedCoreRoute("identity", subpath, query, "identity", "tim", "TIM") default: return core.Result{ Value: coreerr.E("display.resolveCoreRoute", "unknown core route: "+segment, nil), @@ -238,6 +242,61 @@ func (s *Service) resolveUnavailableCoreRoute(route, subpath string, query url.V } } +func (s *Service) resolveServiceBackedCoreRoute(route, subpath string, query url.Values, serviceNames ...string) core.Result { + for _, serviceName := range serviceNames { + serviceName = strings.TrimSpace(serviceName) + if serviceName == "" { + continue + } + serviceResult := s.Core().Service(serviceName) + if !serviceResult.OK { + continue + } + payload := map[string]any{ + "route": route, + "service": serviceName, + "subpath": subpath, + "query": query, + "value": serviceResult.Value, + "actions": s.actionsForService(serviceName), + "services": s.Core().Services(), + } + return core.Result{ + Value: map[string]any{ + "content_type": "text/html", + "body": s.renderSchemeBody(route, payload), + "route": route, + "service": serviceName, + "value": serviceResult.Value, + }, + OK: true, + } + } + return s.resolveUnavailableCoreRoute(route, subpath, query) +} + +func (s *Service) actionsForService(serviceName string) []string { + if strings.TrimSpace(serviceName) == "" { + return nil + } + prefixes := []string{ + serviceName + ".", + "core." + serviceName + ".", + "gui." + serviceName + ".", + } + actions := make([]string, 0) + for _, actionName := range s.Core().Actions() { + for _, prefix := range prefixes { + if strings.HasPrefix(actionName, prefix) { + actions = append(actions, actionName) + break + } + } + } + sort.Strings(actions) + return actions +} + func (s *Service) currentSettingsSnapshot() map[string]any { if s.configFile != nil { var snapshot map[string]any diff --git a/pkg/display/scheme_test.go b/pkg/display/scheme_test.go index f0087e4d..40cee721 100644 --- a/pkg/display/scheme_test.go +++ b/pkg/display/scheme_test.go @@ -172,6 +172,34 @@ func TestScheme_ResolveScheme_Ugly(t *testing.T) { assert.Contains(t, searchPayload["body"].(string), "No matches found in Core storage.") } +func TestScheme_ResolveScheme_ServiceBackedRoute_Good(t *testing.T) { + c := core.New( + core.WithService(Register(nil)), + core.WithName("wallet", func(_ *core.Core) core.Result { + return core.Result{ + Value: map[string]any{ + "balance": "42.0", + "address": "lthn1example", + }, + OK: true, + } + }), + core.WithServiceLock(), + ) + require.True(t, c.ServiceStartup(context.Background(), nil).OK) + + svc := core.MustServiceFor[*Service](c, "display") + svc.registerDefaultSchemes() + + result := svc.ResolveScheme(context.Background(), "core://wallet/treasury?amount=1") + require.True(t, result.OK) + payload := result.Value.(map[string]any) + assert.Equal(t, "wallet", payload["route"]) + assert.Equal(t, "wallet", payload["service"]) + assert.Contains(t, payload["body"].(string), "lthn1example") + assert.Contains(t, payload["body"].(string), "42.0") +} + func TestScheme_ResolveScheme_NetworkPeers_Good(t *testing.T) { c := core.New( core.WithService(Register(nil)), diff --git a/pkg/display/sidecar.go b/pkg/display/sidecar.go index 17a902ac..755f39b4 100644 --- a/pkg/display/sidecar.go +++ b/pkg/display/sidecar.go @@ -10,12 +10,7 @@ import ( func (s *Service) registerSidecarActions() { if strings.TrimSpace(core.Env("CORE_DENO_ENABLE")) != "" && s.sidecar == nil { - manager := deno.New(deno.Options{ - Binary: strings.TrimSpace(core.Env("CORE_DENO_BINARY")), - Dir: strings.TrimSpace(core.Env("CORE_DENO_DIR")), - Args: splitCommandArgs(core.Env("CORE_DENO_ARGS")), - }) - s.sidecar = manager + s.sidecar = s.ensureSidecar() _, _ = s.sidecar.Start(context.Background()) } @@ -32,6 +27,17 @@ func (s *Service) registerSidecarActions() { s.Core().Action("display.sidecar.status", func(_ context.Context, _ core.Options) core.Result { return core.Result{Value: s.ensureSidecar().Status(), OK: true} }) + s.Core().Action("core.deno.sidecar.start", func(ctx context.Context, _ core.Options) core.Result { + status, err := s.ensureSidecar().Start(ctx) + return core.Result{}.New(status, err) + }) + s.Core().Action("core.deno.sidecar.stop", func(ctx context.Context, _ core.Options) core.Result { + status, err := s.ensureSidecar().Stop(ctx) + return core.Result{}.New(status, err) + }) + s.Core().Action("core.deno.sidecar.status", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: s.ensureSidecar().Status(), OK: true} + }) } func (s *Service) ensureSidecar() *deno.Manager { diff --git a/pkg/marketplace/marketplace.go b/pkg/marketplace/marketplace.go index cb60ad79..3da6024b 100644 --- a/pkg/marketplace/marketplace.go +++ b/pkg/marketplace/marketplace.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "encoding/json" "errors" "fmt" "io" @@ -105,6 +106,17 @@ func VerifyManifest(manifest Manifest) error { return nil } +func (i Installer) Verify(ctx context.Context, manifestURL string) (Manifest, error) { + manifest, err := i.FetchManifest(ctx, manifestURL) + if err != nil { + return Manifest{}, err + } + if err := VerifyManifest(manifest); err != nil { + return Manifest{}, err + } + return manifest, nil +} + func (i Installer) Install(ctx context.Context, manifest Manifest) (string, error) { if strings.TrimSpace(i.InstallDir) == "" { return "", errors.New("install dir is required") @@ -148,9 +160,39 @@ func (i Installer) Install(ctx context.Context, manifest Manifest) (string, erro if output, err := cmd.CombinedOutput(); err != nil { return "", fmt.Errorf("git clone failed: %w: %s", err, strings.TrimSpace(string(output))) } + if err := writeInstalledManifest(targetDir, manifest); err != nil { + return "", err + } return targetDir, nil } +func (i Installer) List(ctx context.Context, registryURL string) ([]Manifest, error) { + client := i.HTTPClient + if client == nil { + client = http.DefaultClient + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, registryURL, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= http.StatusBadRequest { + return nil, fmt.Errorf("marketplace list failed: %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxManifestBytes+1)) + if err != nil { + return nil, err + } + if len(body) > maxManifestBytes { + return nil, fmt.Errorf("marketplace list failed: payload exceeds %d bytes", maxManifestBytes) + } + return decodeManifestList(body) +} + func validateManifestName(value string) error { trimmed := strings.TrimSpace(value) if trimmed == "" { @@ -198,3 +240,39 @@ func safeName(value string) string { } return cleaned } + +func decodeManifestList(body []byte) ([]Manifest, error) { + trimmed := strings.TrimSpace(string(body)) + if trimmed == "" { + return nil, nil + } + var manifests []Manifest + if strings.HasPrefix(trimmed, "[") { + if err := json.Unmarshal(body, &manifests); err != nil { + return nil, err + } + return manifests, nil + } + var wrapped struct { + Manifests []Manifest `json:"manifests" yaml:"manifests"` + } + if err := yaml.Unmarshal(body, &wrapped); err == nil && wrapped.Manifests != nil { + return wrapped.Manifests, nil + } + if err := yaml.Unmarshal(body, &manifests); err != nil { + return nil, err + } + return manifests, nil +} + +func writeInstalledManifest(targetDir string, manifest Manifest) error { + manifestDir := filepath.Join(targetDir, ".core") + if err := os.MkdirAll(manifestDir, 0o755); err != nil { + return err + } + data, err := yaml.Marshal(manifest) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(manifestDir, "marketplace.yaml"), data, 0o644) +} diff --git a/pkg/marketplace/marketplace_test.go b/pkg/marketplace/marketplace_test.go index 5d046afa..2b52ce61 100644 --- a/pkg/marketplace/marketplace_test.go +++ b/pkg/marketplace/marketplace_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) func signedManifest(t *testing.T, manifest Manifest) Manifest { @@ -152,6 +153,10 @@ func TestMarketplace_Install_Good(t *testing.T) { assert.Contains(t, string(contents), "clone") assert.Contains(t, string(contents), "--branch") assert.Contains(t, string(contents), "main") + + installedManifest, err := os.ReadFile(filepath.Join(targetDir, ".core", "marketplace.yaml")) + require.NoError(t, err) + assert.Contains(t, string(installedManifest), "name: Core UI") } func TestMarketplace_Install_Bad(t *testing.T) { @@ -191,6 +196,43 @@ func TestMarketplace_Install_Ugly(t *testing.T) { assert.Contains(t, err.Error(), "git clone failed") } +func TestMarketplace_Verify_Good(t *testing.T) { + manifest := signedManifest(t, Manifest{ + Name: "core-ui", + Version: "1.2.3", + Repository: "https://example.com/core-ui.git", + Ref: "main", + }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + data, err := yaml.Marshal(manifest) + require.NoError(t, err) + _, _ = w.Write(data) + })) + t.Cleanup(server.Close) + + installer := Installer{HTTPClient: server.Client()} + verified, err := installer.Verify(context.Background(), server.URL) + require.NoError(t, err) + assert.Equal(t, manifest.Name, verified.Name) + assert.Equal(t, manifest.Ref, verified.Ref) +} + +func TestMarketplace_List_Good(t *testing.T) { + manifests := []Manifest{ + {Name: "core-ui", Version: "1.2.3"}, + {Name: "core-chat", Version: "0.9.0"}, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("manifests:\n - name: core-ui\n version: 1.2.3\n - name: core-chat\n version: 0.9.0\n")) + })) + t.Cleanup(server.Close) + + installer := Installer{HTTPClient: server.Client()} + listed, err := installer.List(context.Background(), server.URL) + require.NoError(t, err) + assert.Equal(t, manifests, listed) +} + func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } diff --git a/ui/src/chat/chat-state.service.ts b/ui/src/chat/chat-state.service.ts index 2453f54f..f93df97f 100644 --- a/ui/src/chat/chat-state.service.ts +++ b/ui/src/chat/chat-state.service.ts @@ -130,12 +130,14 @@ export class ChatStateService { } async resetSettings(): Promise { - const reset = await this.invoke('gui.chat.settings.reset'); - if (reset) { - this.settings.set(reset); - if (reset.default_model) { - this.selectedModel.set(reset.default_model); - } + const defaults = await this.invoke('gui.chat.settings.defaults'); + if (!defaults) { + return; + } + const saved = await this.invoke('gui.chat.settings.save', defaults); + if (saved) { + this.settings.set(saved); + this.selectedModel.set(saved.default_model || ''); } } @@ -479,6 +481,17 @@ export class ChatStateService { if (route === 'gui.chat.settings.load') { return this.settings() as T; } + if (route === 'gui.chat.settings.defaults') { + return { + temperature: 1, + top_p: 0.95, + top_k: 64, + max_tokens: 2048, + context_window: 8192, + system_prompt: 'You are a helpful assistant.', + default_model: '', + } as T; + } if (route === 'gui.chat.settings.save') { const settings = payload as ChatSettings; this.settings.set(settings); @@ -488,15 +501,7 @@ export class ChatStateService { return settings as T; } if (route === 'gui.chat.settings.reset') { - const defaults: ChatSettings = { - temperature: 1, - top_p: 0.95, - top_k: 64, - max_tokens: 2048, - context_window: 8192, - system_prompt: 'You are a helpful assistant.', - default_model: '', - }; + const defaults = (await this.mockInvoke('gui.chat.settings.defaults')) as ChatSettings; this.settings.set(defaults); return defaults as T; } diff --git a/ui/src/chat/settings-panel.component.ts b/ui/src/chat/settings-panel.component.ts index 2cafda3c..d1a05fbe 100644 --- a/ui/src/chat/settings-panel.component.ts +++ b/ui/src/chat/settings-panel.component.ts @@ -12,7 +12,7 @@ import { ChatSettings, ModelEntry } from './chat.types';
Inference settings
- +
diff --git a/ui/src/generated/core-gui-chat.bindings.ts b/ui/src/generated/core-gui-chat.bindings.ts index fa163c77..71a3b08f 100644 --- a/ui/src/generated/core-gui-chat.bindings.ts +++ b/ui/src/generated/core-gui-chat.bindings.ts @@ -14,6 +14,7 @@ declare global { export interface ChatRouteMap { 'gui.chat.models': { request: void; response: ModelEntry[] }; + 'gui.chat.settings.defaults': { request: void; response: ChatSettings }; 'gui.chat.settings.load': { request: void; response: ChatSettings }; 'gui.chat.settings.save': { request: ChatSettings; response: ChatSettings }; 'gui.chat.settings.reset': { request: void; response: ChatSettings };