Changeset 0.25.0 (#160)
This commit is contained in:
17
server/database/migrations/022_surfaces.sql
Normal file
17
server/database/migrations/022_surfaces.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- 022_surfaces.sql
|
||||
-- Surface registry for dynamic surface lifecycle (v0.25.0)
|
||||
-- Core surfaces are seeded at startup. Extension surfaces are uploaded via admin.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS surface_registry (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
manifest JSONB NOT NULL DEFAULT '{}',
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
source TEXT NOT NULL DEFAULT 'core',
|
||||
installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE surface_registry IS 'Registry of all surfaces (core + extension). Controls enable/disable and stores manifest metadata.';
|
||||
COMMENT ON COLUMN surface_registry.source IS 'core = built-in, extension = uploaded via admin';
|
||||
COMMENT ON COLUMN surface_registry.enabled IS 'Admin toggle — disabled surfaces redirect to / and hide from nav';
|
||||
@@ -287,12 +287,21 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
var personaID string // tracks active persona for KB scoping
|
||||
var personaThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1)
|
||||
|
||||
// v0.25.0: Query channel metadata once — used by group leader check, tool context,
|
||||
// and execution context. Avoids redundant SELECTs on channels.
|
||||
var channelType string
|
||||
var channelTeamID *string
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&channelType, &channelTeamID)
|
||||
|
||||
teamID := ""
|
||||
if channelTeamID != nil {
|
||||
teamID = *channelTeamID
|
||||
}
|
||||
|
||||
// v0.23.2: Group leader default — when type=group and no @mention, use the leader persona
|
||||
if req.PersonaID == "" && extractFirstMention(req.Content) == "" {
|
||||
var channelType string
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT COALESCE(type, 'direct') FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&channelType)
|
||||
if channelType == "group" {
|
||||
// Find the leader persona via channel_participants → persona_group_members
|
||||
var leaderPersonaID string
|
||||
@@ -493,6 +502,16 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
// Resolve effective workspace: channel.workspace_id > project.workspace_id
|
||||
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
|
||||
|
||||
// ── Tool context (v0.25.0) ─────────────────
|
||||
// Uses channel metadata queried above (channelType, teamID).
|
||||
tctx := tools.ToolContext{
|
||||
ChannelType: channelType,
|
||||
WorkspaceID: workspaceID,
|
||||
TeamID: teamID,
|
||||
IsVisitor: isSessionAuth(c),
|
||||
// PersonaID set below after persona resolution
|
||||
}
|
||||
|
||||
// ── @mention routing (v0.23.0 / v0.23.1) ─────────────────────────────────
|
||||
// Resolve @persona-handle, @model-id, or @username.
|
||||
// - User mention → skip completion, notify recipient (v0.23.1)
|
||||
@@ -637,9 +656,10 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Attach tool definitions if model supports tool calling and tools are available
|
||||
tctx.PersonaID = personaID // finalize after all persona resolution (@mention, group leader, etc.)
|
||||
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
||||
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
||||
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID)
|
||||
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
|
||||
}
|
||||
|
||||
// ── Permission pre-flight ─────────────────────────────────────────────
|
||||
@@ -787,7 +807,12 @@ func (h *CompletionHandler) multiModelStream(
|
||||
}
|
||||
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
||||
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
||||
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID)
|
||||
tctx := tools.ToolContext{
|
||||
WorkspaceID: workspaceID,
|
||||
PersonaID: personaID,
|
||||
IsVisitor: isSessionAuth(c),
|
||||
}
|
||||
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
|
||||
}
|
||||
|
||||
// Apply provider-specific request hooks (v0.22.1)
|
||||
@@ -823,25 +848,21 @@ func (h *CompletionHandler) multiModelStream(
|
||||
// buildToolDefs converts registered tools to provider-format tool definitions.
|
||||
// If includeBrowser is true, also fetches tool schemas from enabled browser extensions.
|
||||
// Tools whose names appear in disabledTools are excluded.
|
||||
// Workspace tools are excluded when workspaceID is empty (no workspace bound).
|
||||
func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool, disabledTools []string, workspaceID string) []providers.ToolDef {
|
||||
// v0.25.0: Tools self-declare availability via predicates on ToolContext.
|
||||
// v0.25.0: Persona tool grants apply as a second-pass allowlist when personaID
|
||||
// has explicit grants. Empty grants = inherit all (backward compat).
|
||||
func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool, disabledTools []string, tctx tools.ToolContext, personaID string) []providers.ToolDef {
|
||||
// Build disabled set for O(1) lookup
|
||||
disabled := make(map[string]bool, len(disabledTools))
|
||||
for _, name := range disabledTools {
|
||||
disabled[name] = true
|
||||
}
|
||||
|
||||
// If no workspace is bound, disable workspace + git tools
|
||||
if workspaceID == "" {
|
||||
for _, name := range tools.WorkspaceToolNames() {
|
||||
disabled[name] = true
|
||||
}
|
||||
for _, name := range tools.GitToolNames() {
|
||||
disabled[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
allTools := tools.AllDefinitionsFiltered(disabled)
|
||||
// v0.25.0: Tools self-declare availability via predicates.
|
||||
// AvailableFor evaluates each tool's Availability() against tctx,
|
||||
// then applies the disabled set. Replaces manual WorkspaceToolNames()
|
||||
// and GitToolNames() suppression.
|
||||
allTools := tools.AvailableFor(tctx, disabled)
|
||||
defs := make([]providers.ToolDef, 0, len(allTools))
|
||||
for _, t := range allTools {
|
||||
defs = append(defs, providers.ToolDef{
|
||||
@@ -892,6 +913,28 @@ func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, in
|
||||
}
|
||||
}
|
||||
|
||||
// v0.25.0: Persona tool grants — second-pass allowlist.
|
||||
// If the active persona has explicit tool grants, restrict to only those tools.
|
||||
// Empty grants = persona inherits all context-available tools (backward compat).
|
||||
if personaID != "" && h.stores.Personas != nil {
|
||||
grants, err := h.stores.Personas.GetToolGrants(ctx, personaID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to load tool grants for persona %s: %v", personaID, err)
|
||||
} else if len(grants) > 0 {
|
||||
allowed := make(map[string]bool, len(grants))
|
||||
for _, g := range grants {
|
||||
allowed[g] = true
|
||||
}
|
||||
filtered := defs[:0]
|
||||
for _, d := range defs {
|
||||
if allowed[d.Function.Name] {
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
}
|
||||
defs = filtered
|
||||
}
|
||||
}
|
||||
|
||||
return defs
|
||||
}
|
||||
|
||||
|
||||
@@ -547,7 +547,24 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
|
||||
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
||||
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
||||
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID)
|
||||
// v0.25.0: Build ToolContext with channel metadata
|
||||
var chType string
|
||||
var chTeamID *string
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&chType, &chTeamID)
|
||||
msgTeamID := ""
|
||||
if chTeamID != nil {
|
||||
msgTeamID = *chTeamID
|
||||
}
|
||||
tctx := tools.ToolContext{
|
||||
ChannelType: chType,
|
||||
WorkspaceID: workspaceID,
|
||||
TeamID: msgTeamID,
|
||||
PersonaID: personaID,
|
||||
IsVisitor: isSessionAuth(c),
|
||||
}
|
||||
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
|
||||
}
|
||||
|
||||
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
|
||||
|
||||
@@ -306,3 +306,48 @@ func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// ── Persona Tool Grants (v0.25.0) ───────────
|
||||
|
||||
// GetPersonaToolGrants returns the tool names granted to a persona.
|
||||
// Empty list = persona inherits all context-available tools.
|
||||
func (h *PersonaHandler) GetPersonaToolGrants(c *gin.Context) {
|
||||
personaID := c.Param("id")
|
||||
grants, err := h.stores.Personas.GetToolGrants(c.Request.Context(), personaID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": grants})
|
||||
}
|
||||
|
||||
// SetPersonaToolGrants replaces the tool grants for a persona.
|
||||
// Sending an empty tool_names array clears all grants (persona inherits all tools).
|
||||
func (h *PersonaHandler) SetPersonaToolGrants(c *gin.Context) {
|
||||
personaID := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
ToolNames []string `json:"tool_names"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert tool names to Grant objects
|
||||
grants := make([]models.Grant, 0, len(req.ToolNames))
|
||||
for _, name := range req.ToolNames {
|
||||
grants = append(grants, models.Grant{
|
||||
PersonaID: personaID,
|
||||
GrantType: models.GrantTypeTool,
|
||||
GrantRef: name,
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.stores.Personas.SetGrants(c.Request.Context(), personaID, grants); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update tool grants"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
297
server/handlers/surfaces.go
Normal file
297
server/handlers/surfaces.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// SurfaceHandler manages surface lifecycle (admin-only).
|
||||
type SurfaceHandler struct {
|
||||
stores store.Stores
|
||||
surfacesDir string // e.g. /data/surfaces — where archives are extracted
|
||||
}
|
||||
|
||||
func NewSurfaceHandler(s store.Stores, surfacesDir ...string) *SurfaceHandler {
|
||||
dir := ""
|
||||
if len(surfacesDir) > 0 {
|
||||
dir = surfacesDir[0]
|
||||
}
|
||||
return &SurfaceHandler{stores: s, surfacesDir: dir}
|
||||
}
|
||||
|
||||
// ListSurfaces returns all registered surfaces with their enabled state.
|
||||
// GET /api/v1/admin/surfaces
|
||||
func (h *SurfaceHandler) ListSurfaces(c *gin.Context) {
|
||||
surfaces, err := h.stores.Surfaces.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
|
||||
return
|
||||
}
|
||||
if surfaces == nil {
|
||||
surfaces = []store.SurfaceRegistration{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"surfaces": surfaces})
|
||||
}
|
||||
|
||||
// GetSurface returns a single surface by ID.
|
||||
// GET /api/v1/admin/surfaces/:id
|
||||
func (h *SurfaceHandler) GetSurface(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
surface, err := h.stores.Surfaces.Get(c.Request.Context(), id)
|
||||
if err != nil || surface == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, surface)
|
||||
}
|
||||
|
||||
// EnableSurface enables a surface.
|
||||
// PUT /api/v1/admin/surfaces/:id/enable
|
||||
func (h *SurfaceHandler) EnableSurface(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.stores.Surfaces.SetEnabled(c.Request.Context(), id, true); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true})
|
||||
}
|
||||
|
||||
// DisableSurface disables a surface. Chat cannot be disabled.
|
||||
// PUT /api/v1/admin/surfaces/:id/disable
|
||||
func (h *SurfaceHandler) DisableSurface(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
// Chat and Admin are required — cannot be disabled
|
||||
if id == "chat" || id == "admin" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": id + " surface cannot be disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Surfaces.SetEnabled(c.Request.Context(), id, false); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": false})
|
||||
}
|
||||
|
||||
// DeleteSurface uninstalls an extension surface. Core surfaces cannot be deleted.
|
||||
// DELETE /api/v1/admin/surfaces/:id
|
||||
func (h *SurfaceHandler) DeleteSurface(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
// Check it's not a core surface
|
||||
surface, err := h.stores.Surfaces.Get(c.Request.Context(), id)
|
||||
if err != nil || surface == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
|
||||
return
|
||||
}
|
||||
if surface.Source == "core" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "core surfaces cannot be uninstalled"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Surfaces.Delete(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete surface"})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Delete static assets from SURFACE_ASSET_DIR/{id}/
|
||||
// TODO: Delete templates from SURFACE_TEMPLATE_DIR/{id}/
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
|
||||
}
|
||||
|
||||
// InstallSurface uploads and installs a .surface archive.
|
||||
// POST /api/v1/admin/surfaces/install (multipart: file)
|
||||
func (h *SurfaceHandler) InstallSurface(c *gin.Context) {
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate extension
|
||||
if !strings.HasSuffix(header.Filename, ".surface") && !strings.HasSuffix(header.Filename, ".zip") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .surface or .zip archive"})
|
||||
return
|
||||
}
|
||||
|
||||
// Size limit: 50MB
|
||||
if header.Size > 50*1024*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
|
||||
return
|
||||
}
|
||||
|
||||
// Read into temp file for zip processing
|
||||
tmpFile, err := os.CreateTemp("", "surface-*.zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||
return
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := io.Copy(tmpFile, file); err != nil {
|
||||
tmpFile.Close()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
||||
return
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Open as zip
|
||||
zr, err := zip.OpenReader(tmpPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
|
||||
return
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
// Find and parse manifest.json
|
||||
var manifest map[string]any
|
||||
for _, f := range zr.File {
|
||||
name := filepath.Base(f.Name)
|
||||
if name == "manifest.json" && !f.FileInfo().IsDir() {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if manifest == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
surfaceID, _ := manifest["id"].(string)
|
||||
title, _ := manifest["title"].(string)
|
||||
if surfaceID == "" || title == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for conflicts with core surfaces
|
||||
existing, _ := h.stores.Surfaces.Get(c.Request.Context(), surfaceID)
|
||||
if existing != nil && existing.Source == "core" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core surface: " + surfaceID})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract static assets (js/, css/, assets/) to surfacesDir/{id}/
|
||||
if h.surfacesDir != "" {
|
||||
destDir := filepath.Join(h.surfacesDir, surfaceID)
|
||||
os.MkdirAll(destDir, 0755)
|
||||
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
// Only extract js/, css/, assets/ directories
|
||||
name := f.Name
|
||||
// Strip leading directory if archive has one (e.g., "my-surface/js/foo.js" → "js/foo.js")
|
||||
if idx := strings.Index(name, "/"); idx > 0 {
|
||||
rest := name[idx+1:]
|
||||
if strings.HasPrefix(rest, "js/") || strings.HasPrefix(rest, "css/") || strings.HasPrefix(rest, "assets/") {
|
||||
name = rest
|
||||
} else if strings.HasPrefix(name, "js/") || strings.HasPrefix(name, "css/") || strings.HasPrefix(name, "assets/") {
|
||||
// Already relative
|
||||
} else {
|
||||
continue // Skip non-static files (templates, manifest)
|
||||
}
|
||||
}
|
||||
|
||||
destPath := filepath.Join(destDir, name)
|
||||
|
||||
// Security: prevent path traversal
|
||||
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
|
||||
continue
|
||||
}
|
||||
|
||||
os.MkdirAll(filepath.Dir(destPath), 0755)
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
continue
|
||||
}
|
||||
io.Copy(out, rc)
|
||||
out.Close()
|
||||
rc.Close()
|
||||
}
|
||||
|
||||
log.Printf("[surfaces] Extracted %s to %s", surfaceID, destDir)
|
||||
}
|
||||
|
||||
// Register in database
|
||||
if err := h.stores.Surfaces.Seed(c.Request.Context(), surfaceID, title, "extension", manifest); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register surface"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": surfaceID,
|
||||
"title": title,
|
||||
"source": "extension",
|
||||
"enabled": true,
|
||||
})
|
||||
}
|
||||
|
||||
// ListEnabledSurfaces returns enabled surface IDs (non-admin, for nav).
|
||||
// GET /api/v1/surfaces
|
||||
func (h *SurfaceHandler) ListEnabledSurfaces(c *gin.Context) {
|
||||
surfaces, err := h.stores.Surfaces.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return only enabled surfaces with minimal info for nav
|
||||
type navSurface struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Route string `json:"route"`
|
||||
}
|
||||
|
||||
var enabled []navSurface
|
||||
for _, s := range surfaces {
|
||||
if !s.Enabled {
|
||||
continue
|
||||
}
|
||||
route, _ := s.Manifest["route"].(string)
|
||||
enabled = append(enabled, navSurface{
|
||||
ID: s.ID,
|
||||
Title: s.Title,
|
||||
Route: route,
|
||||
})
|
||||
}
|
||||
if enabled == nil {
|
||||
enabled = []navSurface{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"surfaces": enabled})
|
||||
}
|
||||
@@ -524,6 +524,10 @@ func main() {
|
||||
protected.POST("/chat/completions", comp.Complete)
|
||||
protected.GET("/tools", comp.ListTools)
|
||||
|
||||
// Surface discovery (v0.25.0)
|
||||
surfaceH := handlers.NewSurfaceHandler(stores)
|
||||
protected.GET("/surfaces", surfaceH.ListEnabledSurfaces)
|
||||
|
||||
// Summarize & Continue (backed by compaction service)
|
||||
compactionSvc := compaction.NewService(stores, roleResolver)
|
||||
summarize := handlers.NewSummarizeHandler(compactionSvc)
|
||||
@@ -580,6 +584,8 @@ func main() {
|
||||
protected.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar)
|
||||
protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||
protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
|
||||
protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0
|
||||
protected.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0
|
||||
|
||||
// Notes
|
||||
notes := handlers.NewNoteHandler(stores)
|
||||
@@ -826,6 +832,8 @@ func main() {
|
||||
admin.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar)
|
||||
admin.GET("/personas/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0
|
||||
admin.PUT("/personas/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0
|
||||
admin.GET("/personas/:id/tool-grants", personaAdm.GetPersonaToolGrants) // v0.25.0
|
||||
admin.PUT("/personas/:id/tool-grants", personaAdm.SetPersonaToolGrants) // v0.25.0
|
||||
|
||||
// Admin memory review (v0.18.0)
|
||||
adminMemH := handlers.NewMemoryHandler(stores)
|
||||
@@ -941,6 +949,19 @@ func main() {
|
||||
admin.PUT("/routing/policies/:id", routingAdm.UpdatePolicy)
|
||||
admin.DELETE("/routing/policies/:id", routingAdm.DeletePolicy)
|
||||
admin.POST("/routing/test", routingAdm.TestRouting)
|
||||
|
||||
// Surface lifecycle management (v0.25.0)
|
||||
surfacesDir := ""
|
||||
if cfg.StoragePath != "" {
|
||||
surfacesDir = cfg.StoragePath + "/surfaces"
|
||||
}
|
||||
surfaceAdm := handlers.NewSurfaceHandler(stores, surfacesDir)
|
||||
admin.GET("/surfaces", surfaceAdm.ListSurfaces)
|
||||
admin.GET("/surfaces/:id", surfaceAdm.GetSurface)
|
||||
admin.POST("/surfaces/install", surfaceAdm.InstallSurface)
|
||||
admin.PUT("/surfaces/:id/enable", surfaceAdm.EnableSurface)
|
||||
admin.PUT("/surfaces/:id/disable", surfaceAdm.DisableSurface)
|
||||
admin.DELETE("/surfaces/:id", surfaceAdm.DeleteSurface)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -954,48 +975,16 @@ func main() {
|
||||
// Login page — no auth required
|
||||
base.GET("/login", pageEngine.RenderLogin())
|
||||
|
||||
// Authenticated page routes
|
||||
pageRoutes := base.Group("")
|
||||
pageRoutes.Use(middleware.AuthOrRedirect(cfg))
|
||||
{
|
||||
// Chat surface (default) — bridge to existing SPA
|
||||
pageRoutes.GET("/", pageEngine.RenderSurface("chat"))
|
||||
pageRoutes.GET("/chat/:chatID", pageEngine.RenderSurface("chat"))
|
||||
|
||||
// Editor surface — server renders layout, JS builds CodeMirror inside
|
||||
pageRoutes.GET("/editor", pageEngine.RenderSurface("editor"))
|
||||
pageRoutes.GET("/editor/:wsId", pageEngine.RenderSurface("editor"))
|
||||
|
||||
// Notes surface
|
||||
pageRoutes.GET("/notes", pageEngine.RenderSurface("notes"))
|
||||
pageRoutes.GET("/notes/:noteId", pageEngine.RenderSurface("notes"))
|
||||
}
|
||||
|
||||
// Admin pages — auth + admin role required
|
||||
adminPages := base.Group("/admin")
|
||||
adminPages.Use(middleware.AuthOrRedirect(cfg))
|
||||
adminPages.Use(middleware.RequireAdminPage())
|
||||
{
|
||||
adminPages.GET("", pageEngine.RenderSurface("admin"))
|
||||
adminPages.GET("/:section", pageEngine.RenderSurface("admin"))
|
||||
}
|
||||
|
||||
// Settings pages — auth required
|
||||
settingsPages := base.Group("/settings")
|
||||
settingsPages.Use(middleware.AuthOrRedirect(cfg))
|
||||
{
|
||||
settingsPages.GET("", pageEngine.RenderSurface("settings"))
|
||||
settingsPages.GET("/:section", pageEngine.RenderSurface("settings"))
|
||||
}
|
||||
|
||||
// ── Workflow routes (v0.24.3) ────────────
|
||||
// Anonymous session entry point for workflow channels.
|
||||
// AuthOrSession tries JWT first, falls back to session cookie.
|
||||
wfPages := base.Group("/w")
|
||||
wfPages.Use(middleware.AuthOrSession(cfg, stores))
|
||||
{
|
||||
wfPages.GET("/:id", pageEngine.RenderWorkflow())
|
||||
}
|
||||
// v0.25.0: Surface routes generated from manifest registry.
|
||||
// Replaces manual per-surface route blocks.
|
||||
pageEngine.RegisterPageRoutes(base, pages.PageRouteMiddleware{
|
||||
Authenticated: middleware.AuthOrRedirect(cfg),
|
||||
Admin: []gin.HandlerFunc{
|
||||
middleware.AuthOrRedirect(cfg),
|
||||
middleware.RequireAdminPage(),
|
||||
},
|
||||
Session: middleware.AuthOrSession(cfg, stores),
|
||||
})
|
||||
|
||||
// Workflow API — session participants can send messages + trigger completions
|
||||
wfAPI := base.Group("/api/v1/w")
|
||||
|
||||
@@ -77,6 +77,11 @@ type RoleSelection struct {
|
||||
}
|
||||
|
||||
// ── Page data structs ────────────────────────
|
||||
//
|
||||
// v0.25.0: Each loader function is a "data provider" keyed by surface ID.
|
||||
// The surface manifest's DataRequires field references these keys.
|
||||
// Currently 1:1 (one loader per surface). Future: composite loaders
|
||||
// that assemble data from multiple providers for dashboard-style surfaces.
|
||||
|
||||
// AdminPageData is what the admin surface templates receive.
|
||||
type AdminPageData struct {
|
||||
@@ -129,6 +134,16 @@ func (e *Engine) registerLoaders() {
|
||||
e.RegisterLoader("settings", e.settingsLoader)
|
||||
}
|
||||
|
||||
// ListDataProviders returns the keys of all registered data providers.
|
||||
// Used for manifest validation — DataRequires entries must match a key here.
|
||||
func (e *Engine) ListDataProviders() []string {
|
||||
keys := make([]string, 0, len(e.loaders))
|
||||
for k := range e.loaders {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// ── Admin loader ─────────────────────────────
|
||||
// Pre-loads ALL dropdown data. Fixes bugs #1 (model roles) and #2 (team scope).
|
||||
|
||||
@@ -299,7 +314,7 @@ func sectionCategory(section string) string {
|
||||
return "ai"
|
||||
case "health", "routing", "capabilities":
|
||||
return "routing"
|
||||
case "settings", "storage", "extensions":
|
||||
case "settings", "storage", "extensions", "channels", "surfaces":
|
||||
return "system"
|
||||
case "usage", "audit", "stats":
|
||||
return "monitoring"
|
||||
|
||||
@@ -39,9 +39,28 @@ type Engine struct {
|
||||
cfg *config.Config
|
||||
stores store.Stores
|
||||
loaders map[string]DataLoaderFunc
|
||||
surfaces []SurfaceManifest // v0.25.0: registered surface definitions
|
||||
devMode bool
|
||||
}
|
||||
|
||||
// SurfaceManifest describes a surface's registration. Core surfaces are
|
||||
// registered in Go at startup. Extension surfaces will be registered from
|
||||
// manifest files (future).
|
||||
type SurfaceManifest struct {
|
||||
ID string `json:"id"` // unique identifier: "chat", "editor", "my-dashboard"
|
||||
Route string `json:"route"` // primary URL pattern: "/", "/editor/:wsId"
|
||||
AltRoutes []string `json:"alt_routes"` // additional URL patterns: ["/chat/:chatID"]
|
||||
Title string `json:"title"` // human-readable: "Chat", "Editor"
|
||||
Template string `json:"template"` // Go template name: "surface-chat", "surface-editor"
|
||||
Components []string `json:"components"` // component IDs used: ["chat-pane", "file-tree"]
|
||||
DataRequires []string `json:"data_requires"` // data loader keys: ["workspace", "models"]
|
||||
Scripts []string `json:"scripts"` // JS files (surface-specific, beyond base.html common)
|
||||
Styles []string `json:"styles"` // CSS files (surface-specific)
|
||||
Auth string `json:"auth"` // "authenticated", "public", "session", "admin"
|
||||
Layout string `json:"layout"` // default pane layout preset: "single", "editor"
|
||||
Source string `json:"source"` // "core" or "extension"
|
||||
}
|
||||
|
||||
// BannerConfig holds environment banner settings.
|
||||
type BannerConfig struct {
|
||||
Text string `json:"text"`
|
||||
@@ -66,6 +85,12 @@ type PageData struct {
|
||||
Theme string // "dark", "light", or "" (= use default)
|
||||
SurfaceSettings any // JSON-serialized to window.__SETTINGS__
|
||||
|
||||
// v0.25.0: Surface manifest — layout preset, components, etc.
|
||||
Manifest *SurfaceManifest `json:"manifest,omitempty"`
|
||||
|
||||
// v0.25.0: Enabled surface IDs for conditional nav rendering.
|
||||
EnabledSurfaces []string `json:"-"`
|
||||
|
||||
// v0.22.7: Login/splash page fields
|
||||
InstanceName string // branding: instance display name
|
||||
LogoURL string // branding: custom logo URL
|
||||
@@ -74,6 +99,17 @@ type PageData struct {
|
||||
AuthMode string // v0.24.1: "builtin", "mtls", "oidc"
|
||||
}
|
||||
|
||||
// SurfaceEnabled returns true if the given surface ID is in the EnabledSurfaces list.
|
||||
// Used by templates: {{if .SurfaceEnabled "editor"}}
|
||||
func (pd PageData) SurfaceEnabled(id string) bool {
|
||||
for _, s := range pd.EnabledSurfaces {
|
||||
if s == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UserContext is the authenticated user's info available to templates.
|
||||
type UserContext struct {
|
||||
ID string `json:"id"`
|
||||
@@ -96,9 +132,74 @@ func New(cfg *config.Config, stores store.Stores) *Engine {
|
||||
}
|
||||
e.parseTemplates()
|
||||
e.registerLoaders()
|
||||
e.registerCoreSurfaces()
|
||||
e.SeedSurfaces() // v0.25.0: Write core manifests to DB (preserves admin toggle state)
|
||||
return e
|
||||
}
|
||||
|
||||
// registerCoreSurfaces populates the surface manifest list with the five
|
||||
// core surfaces. Extension surfaces will be appended at startup from
|
||||
// extension manifests (future).
|
||||
func (e *Engine) registerCoreSurfaces() {
|
||||
e.surfaces = []SurfaceManifest{
|
||||
{
|
||||
ID: "chat", Route: "/", AltRoutes: []string{"/chat/:chatID"},
|
||||
Title: "Chat", Template: "surface-chat", Auth: "authenticated",
|
||||
Components: []string{"chat-pane", "model-selector", "user-menu"},
|
||||
DataRequires: []string{"chat"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
{
|
||||
ID: "editor", Route: "/editor/:wsId", AltRoutes: []string{"/editor"},
|
||||
Title: "Editor", Template: "surface-editor", Auth: "authenticated",
|
||||
Components: []string{"file-tree", "code-editor", "chat-pane", "note-editor", "model-selector"},
|
||||
DataRequires: []string{"editor"},
|
||||
Layout: "editor", Source: "extension", // Dogfood: editor is the first non-core surface
|
||||
},
|
||||
{
|
||||
ID: "notes", Route: "/notes", AltRoutes: []string{"/notes/:noteId"},
|
||||
Title: "Notes", Template: "surface-notes", Auth: "authenticated",
|
||||
Components: []string{"note-editor", "chat-pane"},
|
||||
DataRequires: []string{"notes"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
{
|
||||
ID: "admin", Route: "/admin/:section", AltRoutes: []string{"/admin"},
|
||||
Title: "Admin", Template: "surface-admin", Auth: "admin",
|
||||
DataRequires: []string{"admin"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
{
|
||||
ID: "settings", Route: "/settings/:section", AltRoutes: []string{"/settings"},
|
||||
Title: "Settings", Template: "surface-settings", Auth: "authenticated",
|
||||
DataRequires: []string{"settings"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
{
|
||||
ID: "workflow", Route: "/w/:id",
|
||||
Title: "Workflow", Template: "workflow", Auth: "session",
|
||||
Components: []string{"chat-pane"},
|
||||
DataRequires: []string{"workflow"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetSurface returns the manifest for a surface by ID, or nil if not found.
|
||||
func (e *Engine) GetSurface(id string) *SurfaceManifest {
|
||||
for i := range e.surfaces {
|
||||
if e.surfaces[i].ID == id {
|
||||
return &e.surfaces[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Surfaces returns all registered surface manifests.
|
||||
func (e *Engine) Surfaces() []SurfaceManifest {
|
||||
return e.surfaces
|
||||
}
|
||||
|
||||
// parseTemplates compiles all embedded templates with the custom FuncMap.
|
||||
func (e *Engine) parseTemplates() {
|
||||
funcMap := template.FuncMap{
|
||||
@@ -191,14 +292,156 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
e.Render(c, "base.html", PageData{
|
||||
Surface: surfaceID,
|
||||
Section: section,
|
||||
User: user,
|
||||
Data: data,
|
||||
Surface: surfaceID,
|
||||
Section: section,
|
||||
User: user,
|
||||
Data: data,
|
||||
Manifest: e.GetSurface(surfaceID),
|
||||
EnabledSurfaces: e.EnabledSurfaceIDs(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PageRouteMiddleware holds middleware handlers for each auth level.
|
||||
// Passed to RegisterPageRoutes by main.go.
|
||||
type PageRouteMiddleware struct {
|
||||
Authenticated gin.HandlerFunc // AuthOrRedirect — for chat, editor, notes, settings
|
||||
Admin []gin.HandlerFunc // AuthOrRedirect + RequireAdminPage
|
||||
Session gin.HandlerFunc // AuthOrSession — for workflow
|
||||
}
|
||||
|
||||
// RegisterPageRoutes generates Gin routes from the registered surface manifests.
|
||||
// Replaces manual per-surface route blocks in main.go.
|
||||
//
|
||||
// Groups surfaces by auth level to avoid redundant middleware chains.
|
||||
// Workflow surfaces use RenderWorkflow() (separate template + data model).
|
||||
// All other surfaces use RenderSurface().
|
||||
func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddleware) {
|
||||
// Group surfaces by auth type
|
||||
byAuth := map[string][]SurfaceManifest{}
|
||||
for _, s := range e.surfaces {
|
||||
byAuth[s.Auth] = append(byAuth[s.Auth], s)
|
||||
}
|
||||
|
||||
// Authenticated surfaces — single middleware group
|
||||
if surfaces, ok := byAuth["authenticated"]; ok && mw.Authenticated != nil {
|
||||
group := base.Group("")
|
||||
group.Use(mw.Authenticated)
|
||||
for _, s := range surfaces {
|
||||
if !e.IsSurfaceEnabled(s.ID) {
|
||||
// Disabled surface → redirect to chat
|
||||
registerRoutes(group, s, e.disabledRedirect())
|
||||
continue
|
||||
}
|
||||
handler := e.RenderSurface(s.ID)
|
||||
registerRoutes(group, s, handler)
|
||||
}
|
||||
}
|
||||
|
||||
// Admin surfaces — auth + admin role middleware
|
||||
if surfaces, ok := byAuth["admin"]; ok && len(mw.Admin) > 0 {
|
||||
for _, s := range surfaces {
|
||||
prefix := routePrefix(s.Route)
|
||||
group := base.Group(prefix)
|
||||
for _, m := range mw.Admin {
|
||||
group.Use(m)
|
||||
}
|
||||
// Admin surfaces are always enabled (needed to manage other surfaces)
|
||||
handler := e.RenderSurface(s.ID)
|
||||
registerRoutesRelative(group, prefix, s, handler)
|
||||
}
|
||||
}
|
||||
|
||||
// Session surfaces (workflow) — session middleware
|
||||
if surfaces, ok := byAuth["session"]; ok && mw.Session != nil {
|
||||
for _, s := range surfaces {
|
||||
if !e.IsSurfaceEnabled(s.ID) {
|
||||
prefix := routePrefix(s.Route)
|
||||
group := base.Group(prefix)
|
||||
group.Use(mw.Session)
|
||||
registerRoutesRelative(group, prefix, s, e.disabledRedirect())
|
||||
continue
|
||||
}
|
||||
prefix := routePrefix(s.Route)
|
||||
group := base.Group(prefix)
|
||||
group.Use(mw.Session)
|
||||
handler := e.surfaceHandler(s)
|
||||
registerRoutesRelative(group, prefix, s, handler)
|
||||
}
|
||||
}
|
||||
|
||||
// Public surfaces — no middleware
|
||||
if surfaces, ok := byAuth["public"]; ok {
|
||||
for _, s := range surfaces {
|
||||
if !e.IsSurfaceEnabled(s.ID) {
|
||||
registerRoutes(base, s, e.disabledRedirect())
|
||||
continue
|
||||
}
|
||||
handler := e.RenderSurface(s.ID)
|
||||
registerRoutes(base, s, handler)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[pages] Registered routes for %d surfaces", len(e.surfaces))
|
||||
}
|
||||
|
||||
// surfaceHandler returns the appropriate Gin handler for a surface.
|
||||
func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
|
||||
if s.ID == "workflow" {
|
||||
return e.RenderWorkflow()
|
||||
}
|
||||
return e.RenderSurface(s.ID)
|
||||
}
|
||||
|
||||
// registerRoutes adds the primary route and alt routes for a surface.
|
||||
// Used for surfaces in the root group (authenticated).
|
||||
func registerRoutes(group *gin.RouterGroup, s SurfaceManifest, handler gin.HandlerFunc) {
|
||||
group.GET(s.Route, handler)
|
||||
for _, alt := range s.AltRoutes {
|
||||
if alt != s.Route {
|
||||
group.GET(alt, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerRoutesRelative adds routes relative to a group prefix.
|
||||
// Used for admin/session surfaces with their own prefix group.
|
||||
// "/admin/:section" with prefix "/admin" → "/:section"
|
||||
func registerRoutesRelative(group *gin.RouterGroup, prefix string, s SurfaceManifest, handler gin.HandlerFunc) {
|
||||
rel := stripPrefix(s.Route, prefix)
|
||||
group.GET(rel, handler)
|
||||
for _, alt := range s.AltRoutes {
|
||||
r := stripPrefix(alt, prefix)
|
||||
if r != rel {
|
||||
group.GET(r, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// routePrefix extracts the first path segment as a group prefix.
|
||||
// "/admin/:section" → "/admin", "/w/:id" → "/w", "/" → ""
|
||||
func routePrefix(route string) string {
|
||||
trimmed := strings.TrimPrefix(route, "/")
|
||||
parts := strings.SplitN(trimmed, "/", 2)
|
||||
if len(parts) == 0 || parts[0] == "" || strings.HasPrefix(parts[0], ":") {
|
||||
return ""
|
||||
}
|
||||
return "/" + parts[0]
|
||||
}
|
||||
|
||||
// stripPrefix removes a prefix from a route path. Returns "" if the
|
||||
// entire path IS the prefix (which Gin interprets as the group root).
|
||||
func stripPrefix(route, prefix string) string {
|
||||
if prefix == "" {
|
||||
return route
|
||||
}
|
||||
rel := strings.TrimPrefix(route, prefix)
|
||||
if rel == "" {
|
||||
rel = ""
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// RenderLogin serves the standalone login page.
|
||||
func (e *Engine) RenderLogin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
70
server/pages/pages_surfaces.go
Normal file
70
server/pages/pages_surfaces.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SeedSurfaces writes core surface manifests to the registry table.
|
||||
// Called once at startup. Does NOT overwrite the enabled flag — admin
|
||||
// toggles survive restarts.
|
||||
func (e *Engine) SeedSurfaces() {
|
||||
ctx := context.Background()
|
||||
for _, s := range e.surfaces {
|
||||
manifest := map[string]any{
|
||||
"route": s.Route,
|
||||
"alt_routes": s.AltRoutes,
|
||||
"template": s.Template,
|
||||
"components": s.Components,
|
||||
"data_requires": s.DataRequires,
|
||||
"auth": s.Auth,
|
||||
"layout": s.Layout,
|
||||
}
|
||||
if err := e.stores.Surfaces.Seed(ctx, s.ID, s.Title, s.Source, manifest); err != nil {
|
||||
log.Printf("[pages] Failed to seed surface %q: %v", s.ID, err)
|
||||
}
|
||||
}
|
||||
log.Printf("[pages] Seeded %d core surfaces into registry", len(e.surfaces))
|
||||
}
|
||||
|
||||
// IsSurfaceEnabled checks if a surface is enabled in the registry.
|
||||
// Chat and Admin are always enabled (system-critical).
|
||||
// Returns true if the surface is not found (fail-open for backward compat).
|
||||
func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
|
||||
// Chat and Admin cannot be disabled — they're system-critical
|
||||
if surfaceID == "chat" || surfaceID == "admin" {
|
||||
return true
|
||||
}
|
||||
ctx := context.Background()
|
||||
sr, err := e.stores.Surfaces.Get(ctx, surfaceID)
|
||||
if err != nil || sr == nil {
|
||||
return true // Not in registry = assume enabled (backward compat)
|
||||
}
|
||||
return sr.Enabled
|
||||
}
|
||||
|
||||
// EnabledSurfaceIDs returns a list of enabled surface IDs for nav rendering.
|
||||
func (e *Engine) EnabledSurfaceIDs() []string {
|
||||
ctx := context.Background()
|
||||
ids, err := e.stores.Surfaces.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[pages] Failed to load enabled surfaces: %v", err)
|
||||
// Fallback: return all core surface IDs
|
||||
var all []string
|
||||
for _, s := range e.surfaces {
|
||||
all = append(all, s.ID)
|
||||
}
|
||||
return all
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// disabledRedirect returns a handler that redirects to the chat surface.
|
||||
func (e *Engine) disabledRedirect() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/")
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,11 @@
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/panels.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/surfaces.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/splash.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/pane-container.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/chat-pane.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/user-menu.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/tool-grants.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}">
|
||||
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
|
||||
{{if eq .Surface "editor"}}{{template "css-editor" .}}{{end}}
|
||||
<style>
|
||||
@@ -74,6 +79,7 @@
|
||||
window.__SURFACE__ = '{{.Surface}}';
|
||||
window.__PAGE_DATA__ = {{.Data | toJSON}};
|
||||
window.__USER__ = {{.User | toJSON}};
|
||||
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
|
||||
</script>
|
||||
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app-state.js?v={{.Version}}"></script>
|
||||
@@ -87,6 +93,13 @@
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-primitives-additions.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-core.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pages.js?v={{.Version}}"></script>
|
||||
{{/* v0.25.0: Component scripts — available on all surfaces */}}
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/user-menu.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/model-selector.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/file-tree.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/code-editor.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/note-editor.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pane-container.js?v={{.Version}}"></script>
|
||||
|
||||
{{if eq .Surface "chat"}}{{template "scripts-chat" .}}{{end}}
|
||||
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}}
|
||||
|
||||
@@ -3,12 +3,27 @@
|
||||
Usage: {{template "chat-pane" dict "ID" "main"}}
|
||||
Creates mount points: {ID}ChatMessages, {ID}ChatInput, {ID}SendBtn, {ID}ModelSel
|
||||
ChatPane.create() in chat-pane.js binds to these IDs.
|
||||
|
||||
The header bar ({ID}ChatHeader) is hidden by default.
|
||||
Standalone panes (editor assist) show it for chat switching + model selection.
|
||||
*/}}
|
||||
{{define "chat-pane"}}
|
||||
<div class="chat-pane" id="{{.ID}}ChatPane">
|
||||
<div class="chat-pane-header" id="{{.ID}}ChatHeader" style="display:none;">
|
||||
<div class="chat-pane-header-left">
|
||||
<select class="chat-pane-chat-select" id="{{.ID}}ChatSelect" title="Switch chat">
|
||||
<option value="">New conversation</option>
|
||||
</select>
|
||||
<button class="chat-pane-new-btn" id="{{.ID}}ChatNewBtn" title="New chat">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="chat-pane-header-right">
|
||||
<div class="chat-pane-model-sel" id="{{.ID}}ModelSel"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-pane-messages" id="{{.ID}}ChatMessages"></div>
|
||||
<div class="chat-pane-input-bar">
|
||||
<div class="chat-pane-model-sel" id="{{.ID}}ModelSel"></div>
|
||||
<div class="chat-pane-input-wrap">
|
||||
<div class="chat-pane-input" id="{{.ID}}ChatInput"></div>
|
||||
<button class="chat-pane-send" id="{{.ID}}SendBtn" title="Send">
|
||||
|
||||
30
server/pages/templates/components/code-editor.html
Normal file
30
server/pages/templates/components/code-editor.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{{/*
|
||||
Code Editor Component — tabbed CM6 editor with tab bar, save, and status bar.
|
||||
Usage: {{template "code-editor" dict "ID" "editor"}}
|
||||
Creates mount points: {ID}EditorTabs, {ID}EditorContent, {ID}EditorWelcome,
|
||||
{ID}EditorStatus, {ID}EditorStatusFile, {ID}EditorStatusLang, {ID}EditorStatusBranch.
|
||||
CodeEditor.create() in code-editor.js binds to these IDs.
|
||||
*/}}
|
||||
{{define "code-editor"}}
|
||||
<div class="code-editor" id="{{.ID}}CodeEditor">
|
||||
<div class="code-editor-tabs" id="{{.ID}}EditorTabs">
|
||||
{{/* Populated by CodeEditor.openFile() */}}
|
||||
</div>
|
||||
<div class="code-editor-content" id="{{.ID}}EditorContent">
|
||||
<div class="code-editor-welcome" id="{{.ID}}EditorWelcome">
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-3);">
|
||||
<div style="text-align:center;">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:0.12;display:inline-block;margin-bottom:10px;"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
|
||||
<p style="margin:0;font-size:14px;">Open a file to start editing</p>
|
||||
<p style="margin:6px 0 0;font-size:12px;">Select from the file tree or Ctrl+P</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="code-editor-statusbar" id="{{.ID}}EditorStatus">
|
||||
<span id="{{.ID}}EditorStatusFile"></span>
|
||||
<span id="{{.ID}}EditorStatusLang"></span>
|
||||
<span id="{{.ID}}EditorStatusBranch"></span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
20
server/pages/templates/components/file-tree.html
Normal file
20
server/pages/templates/components/file-tree.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{{/*
|
||||
File Tree Component — workspace file browser with directory expand/collapse.
|
||||
Usage: {{template "file-tree" dict "ID" "editor"}}
|
||||
Creates mount points: {ID}FileTree, {ID}TreeHeader, {ID}TreeItems, {ID}TreeNewFile.
|
||||
FileTree.create() in file-tree.js binds to these IDs.
|
||||
*/}}
|
||||
{{define "file-tree"}}
|
||||
<div class="file-tree" id="{{.ID}}FileTree">
|
||||
<div class="file-tree-header" id="{{.ID}}TreeHeader">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<span class="file-tree-title">Files</span>
|
||||
<button class="icon-btn" id="{{.ID}}TreeNewFile" title="New file">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="file-tree-items" id="{{.ID}}TreeItems">
|
||||
{{/* Populated by FileTree.refresh() */}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
19
server/pages/templates/components/model-selector.html
Normal file
19
server/pages/templates/components/model-selector.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{{/*
|
||||
Model Selector Component — chat model + persona dropdown with capability badges.
|
||||
Usage: {{template "model-selector" dict "ID" ""}}
|
||||
With empty ID, generates existing IDs: modelDropdown, modelDropdownBtn, etc.
|
||||
With prefix: {{template "model-selector" dict "ID" "editor"}} → editorModelDropdown, etc.
|
||||
ModelSelector.create() in model-selector.js binds to these IDs.
|
||||
*/}}
|
||||
{{define "model-selector"}}
|
||||
<div id="{{.ID}}modelDropdown" class="model-dropdown">
|
||||
<button id="{{.ID}}modelDropdownBtn" class="model-dropdown-btn">
|
||||
<span id="{{.ID}}modelDropdownLabel" class="model-dropdown-label">Select model…</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div id="{{.ID}}modelDropdownMenu" class="model-dropdown-menu">
|
||||
{{/* Populated by ModelSelector.update() */}}
|
||||
</div>
|
||||
</div>
|
||||
<span id="{{.ID}}modelCaps" class="model-caps"></span>
|
||||
{{end}}
|
||||
86
server/pages/templates/components/note-editor.html
Normal file
86
server/pages/templates/components/note-editor.html
Normal file
@@ -0,0 +1,86 @@
|
||||
{{/*
|
||||
Note Editor Component — notes list + editor/reader with folders, search, backlinks.
|
||||
Usage: {{template "note-editor" dict "ID" "main"}}
|
||||
Creates mount points for list view, editor view, and graph view.
|
||||
NoteEditor.create() in note-editor.js binds to these IDs.
|
||||
|
||||
When used in the notes surface (standalone), notes.js wires event listeners
|
||||
and integrates with PanelRegistry. When used in the editor surface (tabbed pane),
|
||||
NoteEditor.create() provides independent lifecycle and event wiring.
|
||||
*/}}
|
||||
{{define "note-editor"}}
|
||||
<div class="note-editor" id="{{.ID}}NoteEditor">
|
||||
{{/* ── List View ───────────────────────── */}}
|
||||
<div class="note-editor-list-view" id="{{.ID}}NotesListView">
|
||||
<div class="notes-toolbar">
|
||||
<button id="{{.ID}}NotesNewBtn" class="btn-small" title="New note">+ New</button>
|
||||
<button id="{{.ID}}NotesTodayBtn" class="btn-small" title="Open daily note">📅 Today</button>
|
||||
<button id="{{.ID}}NotesGraphBtn" class="btn-small" title="Note graph">🕸 Graph</button>
|
||||
<button id="{{.ID}}NotesSelectModeBtn" class="btn-small" title="Select mode">☑ Select</button>
|
||||
</div>
|
||||
<div class="notes-search-row">
|
||||
<input type="text" id="{{.ID}}NotesSearchInput" class="notes-search-input" placeholder="Search notes…" autocomplete="off">
|
||||
</div>
|
||||
<div class="notes-filter-row">
|
||||
<select id="{{.ID}}NotesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>
|
||||
<select id="{{.ID}}NotesSortSelect" class="notes-filter-select">
|
||||
<option value="updated_desc">Last updated</option>
|
||||
<option value="created_desc">Created</option>
|
||||
<option value="alpha">Alphabetical</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="{{.ID}}NotesList" class="notes-list"></div>
|
||||
<div id="{{.ID}}NotesSelectionBar" class="notes-selection-bar" style="display:none;">
|
||||
<label class="notes-select-all"><input type="checkbox" id="{{.ID}}NotesSelectAll"> All</label>
|
||||
<span id="{{.ID}}NotesSelectedCount">0</span> selected
|
||||
<button id="{{.ID}}NotesDeleteSelectedBtn" class="btn-small btn-danger">Delete</button>
|
||||
<button id="{{.ID}}NotesCancelSelectBtn" class="btn-small">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Editor View ─────────────────────── */}}
|
||||
<div class="note-editor-editor-view" id="{{.ID}}NotesEditorView" style="display:none;">
|
||||
<div class="notes-editor-header">
|
||||
<button id="{{.ID}}NotesBackBtn" class="btn-small" title="Back to list">← Back</button>
|
||||
<div style="flex:1"></div>
|
||||
<button id="{{.ID}}NoteEditBtn" class="btn-small" style="display:none;">Edit</button>
|
||||
<button id="{{.ID}}NotePreviewBtn" class="btn-small" style="display:none;">Preview</button>
|
||||
<button id="{{.ID}}NoteCancelEditBtn" class="btn-small" style="display:none;">Cancel</button>
|
||||
<button id="{{.ID}}NoteDeleteBtn" class="btn-small btn-danger" style="display:none;">Delete</button>
|
||||
<button id="{{.ID}}NoteSaveBtn" class="btn-small btn-primary">Save</button>
|
||||
</div>
|
||||
|
||||
{{/* Edit mode */}}
|
||||
<div id="{{.ID}}NoteEditMode" class="notes-editor">
|
||||
<input type="text" id="{{.ID}}NoteEditorTitle" class="notes-title-input" placeholder="Note title">
|
||||
<div class="notes-meta-row">
|
||||
<input type="text" id="{{.ID}}NoteEditorFolder" class="notes-folder-input" placeholder="Folder (e.g. work/project)">
|
||||
<input type="text" id="{{.ID}}NoteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">
|
||||
</div>
|
||||
<div id="{{.ID}}NoteEditorContentContainer" class="notes-content-container">
|
||||
<textarea id="{{.ID}}NoteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Read mode */}}
|
||||
<div id="{{.ID}}NoteReadMode" style="display:none;">
|
||||
<div class="notes-read-view">
|
||||
<h3 id="{{.ID}}NoteReadTitle" class="note-read-title"></h3>
|
||||
<div id="{{.ID}}NoteReadMeta" class="note-read-meta"></div>
|
||||
<div id="{{.ID}}NoteReadContent" class="note-read-content msg-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Backlinks */}}
|
||||
<div id="{{.ID}}NoteBacklinks" class="note-backlinks" style="display:none;">
|
||||
<div class="note-backlinks-header">
|
||||
Backlinks <span id="{{.ID}}NoteBacklinksCount" class="badge">0</span>
|
||||
</div>
|
||||
<div id="{{.ID}}NoteBacklinksList" class="note-backlinks-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Graph View ──────────────────────── */}}
|
||||
<div class="note-editor-graph-view" id="{{.ID}}NotesGraphView" style="display:none;"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
40
server/pages/templates/components/user-menu.html
Normal file
40
server/pages/templates/components/user-menu.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{{/*
|
||||
User Menu Component — reusable user avatar + flyout dropdown.
|
||||
Usage: {{template "user-menu" dict "ID" ""}}
|
||||
With empty ID, generates existing IDs: userMenuBtn, userAvatar, etc.
|
||||
With prefix: {{template "user-menu" dict "ID" "editor"}} → editorUserMenuBtn, etc.
|
||||
UserMenu.create() in user-menu.js binds to these IDs.
|
||||
*/}}
|
||||
{{define "user-menu"}}
|
||||
<div class="user-menu-wrap" id="{{.ID}}userMenuWrap">
|
||||
<button id="{{.ID}}userMenuBtn" class="user-btn">
|
||||
<div id="{{.ID}}userAvatar" class="user-avatar">
|
||||
<span id="{{.ID}}avatarLetter">?</span>
|
||||
</div>
|
||||
<span id="{{.ID}}userName" class="sb-label">User</span>
|
||||
</button>
|
||||
<div id="{{.ID}}userFlyout" class="user-flyout">
|
||||
<button id="{{.ID}}menuSettings" class="flyout-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.32 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
<button id="{{.ID}}menuAdmin" class="flyout-item" style="display:none;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
<span>Admin</span>
|
||||
</button>
|
||||
<button id="{{.ID}}menuTeamAdmin" class="flyout-item" style="display:none;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span>Team Admin</span>
|
||||
</button>
|
||||
<button id="{{.ID}}menuDebug" class="flyout-item" style="display:none;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"/><path d="M12 6V12L16 14"/></svg>
|
||||
<span>Debug Log</span>
|
||||
</button>
|
||||
<hr class="flyout-divider">
|
||||
<button id="{{.ID}}menuSignout" class="flyout-item flyout-danger">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -32,7 +32,7 @@
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Routing
|
||||
</a>
|
||||
<a href="{{$base}}/admin/settings" class="admin-cat-btn{{if eq $section "settings"}} active{{else if eq $section "storage"}} active{{else if eq $section "extensions"}} active{{end}}" data-cat="system">
|
||||
<a href="{{$base}}/admin/settings" class="admin-cat-btn{{if eq $section "settings"}} active{{else if eq $section "storage"}} active{{else if eq $section "extensions"}} active{{else if eq $section "channels"}} active{{else if eq $section "surfaces"}} active{{end}}" data-cat="system">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09"/></svg>
|
||||
System
|
||||
</a>
|
||||
@@ -241,4 +241,5 @@
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-scaffold.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -177,45 +177,20 @@ window.addEventListener('unhandledrejection', function(e) {
|
||||
|
||||
{{/* User / bottom */}}
|
||||
<div class="sidebar-bottom">
|
||||
{{if .SurfaceEnabled "notes"}}
|
||||
<button id="notesBtn" class="sb-btn sidebar-notes-btn">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
<span class="sb-label">Notes</span>
|
||||
</button>
|
||||
{{/* Editor shortcut */}}
|
||||
{{end}}
|
||||
{{if .SurfaceEnabled "editor"}}
|
||||
<a href="{{.BasePath}}/editor" class="sb-btn sidebar-editor-btn" title="Editor">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
<span class="sb-label">Editor</span>
|
||||
</a>
|
||||
{{end}}
|
||||
<div class="sidebar-bottom-divider"></div>
|
||||
<button id="userMenuBtn" class="user-btn">
|
||||
<div id="userAvatar" class="user-avatar">
|
||||
<span id="avatarLetter">?</span>
|
||||
</div>
|
||||
<span id="userName" class="sb-label">User</span>
|
||||
</button>
|
||||
<div id="userFlyout" class="user-flyout">
|
||||
<button id="menuSettings" class="flyout-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.32 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
<button id="menuAdmin" class="flyout-item" style="display:none;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
<span>Admin</span>
|
||||
</button>
|
||||
<button id="menuTeamAdmin" class="flyout-item" style="display:none;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span>Team Admin</span>
|
||||
</button>
|
||||
<button id="menuDebug" class="flyout-item" style="display:none;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"/><path d="M12 6V12L16 14"/></svg>
|
||||
<span>Debug Log</span>
|
||||
</button>
|
||||
<hr class="flyout-divider">
|
||||
<button id="menuSignout" class="flyout-item flyout-danger">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
{{template "user-menu" dict "ID" ""}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -233,16 +208,7 @@ window.addEventListener('unhandledrejection', function(e) {
|
||||
|
||||
{{/* Chat header: model selector + caps + tokens + panel + notif */}}
|
||||
<div class="chat-header">
|
||||
<div id="modelDropdown" class="model-dropdown">
|
||||
<button id="modelDropdownBtn" class="model-dropdown-btn">
|
||||
<span id="modelDropdownLabel" class="model-dropdown-label">Select model…</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div id="modelDropdownMenu" class="model-dropdown-menu">
|
||||
{{/* Populated by UI.updateModelSelector() */}}
|
||||
</div>
|
||||
</div>
|
||||
<span id="modelCaps" class="model-caps"></span>
|
||||
{{template "model-selector" dict "ID" ""}}
|
||||
<div class="chat-header-right">
|
||||
<span id="chatTokenCount" class="token-count" title="Estimated token count"></span>
|
||||
<span id="summarizedHistory" class="summarized-badge" style="display:none;" title="Earlier messages are summarized">📋 Summarized</span>
|
||||
|
||||
@@ -1,127 +1,104 @@
|
||||
{{/*
|
||||
Editor surface — matches switchboard-prototype-editor.jsx.
|
||||
IDE-like layout: topbar + file tree + tabs + code + chat pane.
|
||||
editor-mode.js builds CodeMirror, file tree, etc.
|
||||
Editor surface — v0.25.0 rebuild.
|
||||
Three-pane layout: files | code-editor | <chat, notes>
|
||||
Uses PaneContainer for resizable splits and tabbed right pane.
|
||||
|
||||
Components are SERVER-RENDERED via Go template partials into hidden
|
||||
containers (#editorComponents). The boot script moves them into
|
||||
pane slots created by PaneContainer. This ensures full DOM parity
|
||||
with the chat surface — same buttons, same features, same behavior.
|
||||
*/}}
|
||||
|
||||
{{define "surface-editor"}}
|
||||
<div class="surface-editor">
|
||||
<div class="surface-editor" id="editorSurface">
|
||||
|
||||
{{/* Top Bar */}}
|
||||
<div class="editor-topbar">
|
||||
<a href="{{.BasePath}}/" class="editor-topbar-back">
|
||||
{{/* ── Top Bar ─────────────────────────── */}}
|
||||
<div class="editor-topbar" id="editorTopbar">
|
||||
<a href="{{.BasePath}}/" class="editor-topbar-back" title="Back to chat">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Back
|
||||
</a>
|
||||
<div style="width:1px;height:18px;background:var(--border);"></div>
|
||||
<div class="editor-topbar-sep"></div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
<span style="font-size:13px;font-weight:600;" id="editorWorkspaceName">
|
||||
{{if .Data}}{{.Data.WorkspaceName}}{{else}}Editor{{end}}
|
||||
</span>
|
||||
<div style="display:flex;align-items:center;gap:4px;background:var(--purple-dim);padding:2px 8px;border-radius:4px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--purple)" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="12" cy="6" r="3"/><line x1="12" y1="9" x2="12" y2="15"/></svg>
|
||||
<span style="font-size:11px;font-weight:600;color:var(--purple);font-family:var(--mono);">main</span>
|
||||
</div>
|
||||
<div style="flex:1;"></div>
|
||||
<button class="icon-btn" id="editorNewFileBtn" title="New File">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="editorRefreshBtn" title="Refresh">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="editorSearchBtn" title="Search">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<div style="width:1px;height:18px;background:var(--border);"></div>
|
||||
<button class="icon-btn" id="editorToggleTree" title="Toggle file tree">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="editorToggleChat" title="Toggle chat">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{/* Body: tree + editor + chat */}}
|
||||
<div class="editor-body" id="editorBody">
|
||||
{{/* File Tree */}}
|
||||
<div class="editor-tree" id="editorFileTree">
|
||||
<div class="editor-tree-header">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<span style="font-size:11px;font-weight:600;color:var(--text-2);text-transform:uppercase;letter-spacing:0.4px;flex:1;">Files</span>
|
||||
<button class="icon-btn" id="treeNewFileBtn" title="New file">
|
||||
{{/* Workspace selector dropdown */}}
|
||||
<div class="editor-ws-selector" id="editorWsSelector">
|
||||
<button class="editor-ws-selector-btn" id="editorWsSelectorBtn">
|
||||
<span id="editorWorkspaceName">{{if .Data}}{{.Data.WorkspaceName}}{{else}}Editor{{end}}</span>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="editor-ws-dropdown" id="editorWsDropdown">
|
||||
<div id="editorWsList" class="editor-ws-list">
|
||||
{{/* Populated by JS */}}
|
||||
</div>
|
||||
<div class="editor-ws-dropdown-divider"></div>
|
||||
<button class="editor-ws-dropdown-item editor-ws-new" id="editorWsNewBtn">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New Workspace
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-tree-files" id="editorTreeFiles">
|
||||
{{/* Populated by editor-mode.js */}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Main Editor Area */}}
|
||||
<div class="editor-main" id="editorMain">
|
||||
{{/* Tabs */}}
|
||||
<div class="editor-tabs" id="editorTabs">
|
||||
{{/* Populated by editor-mode.js */}}
|
||||
</div>
|
||||
|
||||
{{/* Code Content */}}
|
||||
<div class="editor-content" id="editorContent">
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-3);">
|
||||
<div style="text-align:center;">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:0.12;display:inline-block;margin-bottom:10px;"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
|
||||
<p style="margin:0;font-size:14px;">Open a file to start editing</p>
|
||||
<p style="margin:6px 0 0;font-size:12px;">Select from the file tree or Ctrl+P</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Status Bar */}}
|
||||
<div class="editor-statusbar" id="editorStatusbar">
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
<div class="editor-topbar-branch" id="editorBranchBadge" style="display:none;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--purple)" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="12" cy="6" r="3"/><line x1="12" y1="9" x2="12" y2="15"/></svg>
|
||||
<span id="editorBranchName" class="editor-topbar-branch-text">main</span>
|
||||
</div>
|
||||
<div style="flex:1;"></div>
|
||||
<button class="icon-btn" id="editorRefreshBtn" title="Refresh files">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
{{template "user-menu" dict "ID" ""}}
|
||||
</div>
|
||||
|
||||
{{/* Chat Pane (resizable) */}}
|
||||
<div id="editorSplitHandle" style="width:5px;background:var(--border);cursor:col-resize;flex-shrink:0;display:none;"></div>
|
||||
<div class="editor-chat" id="editorChatPane" style="display:none;">
|
||||
<div class="editor-chat-header">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span style="font-size:12px;font-weight:600;flex:1;">AI Chat</span>
|
||||
<span class="badge badge-accent" id="editorChatModel">claude-sonnet-4-5</span>
|
||||
{{/* ── Pane Body ───────────────────────── */}}
|
||||
<div class="editor-body" id="editorBody"></div>
|
||||
|
||||
{{/* ── Bootstrap (no workspace) ────────── */}}
|
||||
<div class="editor-bootstrap" id="editorBootstrap" style="display:none;">
|
||||
<div class="editor-bootstrap-card">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="1.5" style="opacity:0.6;margin-bottom:12px;">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<h3 style="margin:0 0 16px;font-size:16px;">Open a Workspace</h3>
|
||||
<div id="editorBootstrapList" style="margin-bottom:16px;">
|
||||
<div style="font-size:12px;color:var(--text-3);">Loading workspaces…</div>
|
||||
</div>
|
||||
<div class="editor-chat-messages" id="editorChatMessages">
|
||||
{{/* Populated by editor-mode.js */}}
|
||||
</div>
|
||||
<div class="editor-chat-input">
|
||||
<div style="display:flex;gap:8px;align-items:flex-end;">
|
||||
<textarea id="editorChatInput" placeholder="Ask about this code…" rows="2"
|
||||
style="flex:1;background:var(--input-bg,var(--bg));border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:8px;font-size:12px;font-family:inherit;outline:none;resize:none;"></textarea>
|
||||
<button id="editorChatSendBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;">Send</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:6px;font-size:10px;color:var(--text-3);">
|
||||
<span id="editorChatContext">Context: none</span>
|
||||
<span>·</span>
|
||||
<span>Cmd+L to focus</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<div style="flex:1;height:1px;background:var(--border);"></div>
|
||||
<span style="font-size:11px;color:var(--text-3);text-transform:uppercase;">or create new</span>
|
||||
<div style="flex:1;height:1px;background:var(--border);"></div>
|
||||
</div>
|
||||
<input type="text" id="editorBootstrapName" class="editor-bootstrap-input" placeholder="Workspace name" value="workspace">
|
||||
<button id="editorBootstrapBtn" class="editor-bootstrap-btn">Create Workspace</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Server-Rendered Components ──────── */}}
|
||||
{{/* Hidden until moved into pane slots by editor-surface.js.
|
||||
Full DOM from Go template partials = feature parity with chat surface. */}}
|
||||
<div id="editorComponents" style="display:none;">
|
||||
{{template "file-tree" dict "ID" "ed"}}
|
||||
{{template "code-editor" dict "ID" "ed"}}
|
||||
{{template "chat-pane" dict "ID" "ed"}}
|
||||
{{template "note-editor" dict "ID" "edNotes"}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
{{end}}
|
||||
|
||||
{{/* CSS: loaded via base.html (variables, layout, primitives, modals, chat, panels, surfaces, splash) */}}
|
||||
{{/* ── CSS ─────────────────────────────────── */}}
|
||||
{{define "css-editor"}}
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/editor-mode.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/editor-surface.css?v={{.Version}}">
|
||||
{{end}}
|
||||
|
||||
{{/* Scripts */}}
|
||||
{{/* ── Scripts ─────────────────────────────── */}}
|
||||
{{define "scripts-editor"}}
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/codemirror/codemirror.bundle.js?v={{.Version}}" onerror="console.warn('[CM6] Bundle not available')"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/editor-mode.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/chat-pane.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/editor-surface.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app.js?v={{.Version}}"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -47,6 +47,7 @@ type Stores struct {
|
||||
CapOverrides CapabilityOverrideStore
|
||||
RoutingPolicies RoutingPolicyStore
|
||||
Sessions SessionStore
|
||||
Surfaces SurfaceRegistryStore // v0.25.0: Surface lifecycle management
|
||||
}
|
||||
|
||||
// =========================================
|
||||
|
||||
@@ -40,5 +40,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
CapOverrides: NewCapOverrideStore(db),
|
||||
RoutingPolicies: NewRoutingPolicyStore(db),
|
||||
Sessions: NewSessionStore(),
|
||||
Surfaces: NewSurfaceRegistryStore(),
|
||||
}
|
||||
}
|
||||
|
||||
117
server/store/postgres/surface_registry.go
Normal file
117
server/store/postgres/surface_registry.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type SurfaceRegistryStore struct{}
|
||||
|
||||
func NewSurfaceRegistryStore() *SurfaceRegistryStore { return &SurfaceRegistryStore{} }
|
||||
|
||||
// Seed upserts a core surface. Called at startup by the page engine
|
||||
// to ensure core surfaces exist in the registry. Does NOT overwrite
|
||||
// the enabled state if the row already exists (admin toggle survives restart).
|
||||
func (s *SurfaceRegistryStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
|
||||
manifestJSON := ToJSON(manifest)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO surface_registry (id, title, source, manifest, enabled, installed_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, true, NOW(), NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = $2,
|
||||
manifest = $4,
|
||||
source = $3,
|
||||
updated_at = NOW()`,
|
||||
// Note: ON CONFLICT does NOT update 'enabled' — preserves admin toggle
|
||||
id, title, source, manifestJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) List(ctx context.Context) ([]store.SurfaceRegistration, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
|
||||
FROM surface_registry ORDER BY source, title`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var surfaces []store.SurfaceRegistration
|
||||
for rows.Next() {
|
||||
var sr store.SurfaceRegistration
|
||||
var manifestJSON []byte
|
||||
if err := rows.Scan(&sr.ID, &sr.Title, &manifestJSON, &sr.Enabled, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
json.Unmarshal(manifestJSON, &sr.Manifest)
|
||||
surfaces = append(surfaces, sr)
|
||||
}
|
||||
return surfaces, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) Get(ctx context.Context, id string) (*store.SurfaceRegistration, error) {
|
||||
var sr store.SurfaceRegistration
|
||||
var manifestJSON []byte
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
|
||||
FROM surface_registry WHERE id = $1`, id).
|
||||
Scan(&sr.ID, &sr.Title, &manifestJSON, &sr.Enabled, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal(manifestJSON, &sr.Manifest)
|
||||
return &sr, nil
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
result, err := DB.ExecContext(ctx,
|
||||
`UPDATE surface_registry SET enabled = $2, updated_at = NOW() WHERE id = $1`,
|
||||
id, enabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) Delete(ctx context.Context, id string) error {
|
||||
// Only allow deleting extension surfaces, not core
|
||||
result, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM surface_registry WHERE id = $1 AND source = 'extension'`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SurfaceRegistryStore) ListEnabled(ctx context.Context) ([]string, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT id FROM surface_registry WHERE enabled = true ORDER BY title`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
36
server/store/surface_registry_iface.go
Normal file
36
server/store/surface_registry_iface.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
// SurfaceRegistryStore — CRUD for the surface_registry table.
|
||||
// Core surfaces are seeded at startup. Extension surfaces are managed via admin API.
|
||||
type SurfaceRegistryStore interface {
|
||||
// Seed upserts a core surface (called at startup by page engine).
|
||||
Seed(ctx context.Context, id, title, source string, manifest map[string]any) error
|
||||
|
||||
// List returns all registered surfaces, ordered by title.
|
||||
List(ctx context.Context) ([]SurfaceRegistration, error)
|
||||
|
||||
// Get returns a single surface by ID.
|
||||
Get(ctx context.Context, id string) (*SurfaceRegistration, error)
|
||||
|
||||
// SetEnabled toggles a surface's enabled state.
|
||||
SetEnabled(ctx context.Context, id string, enabled bool) error
|
||||
|
||||
// Delete removes an extension surface. Core surfaces cannot be deleted.
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// ListEnabled returns IDs of all enabled surfaces.
|
||||
ListEnabled(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// SurfaceRegistration is a row from surface_registry.
|
||||
type SurfaceRegistration struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
Manifest map[string]any `json:"manifest" db:"manifest"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
Source string `json:"source" db:"source"`
|
||||
InstalledAt string `json:"installed_at" db:"installed_at"`
|
||||
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
@@ -17,7 +17,7 @@ func init() {
|
||||
// calculator
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type CalculatorTool struct{}
|
||||
type CalculatorTool struct{ BaseTool }
|
||||
|
||||
func (t *CalculatorTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
@@ -22,6 +22,7 @@ func RegisterConversationSearch(stores store.Stores) {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type conversationSearchTool struct {
|
||||
BaseTool
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ func init() {
|
||||
// datetime
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type DateTimeTool struct{}
|
||||
type DateTimeTool struct{ BaseTool }
|
||||
|
||||
func (t *DateTimeTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
@@ -36,6 +36,7 @@ func RegisterFileRecall(stores store.Stores, objStore storage.ObjectStore) {
|
||||
const maxImageBytes = 10 * 1024 * 1024 // 10 MB cap for base64 images
|
||||
|
||||
type fileRecallTool struct {
|
||||
BaseTool
|
||||
stores store.Stores
|
||||
objStore storage.ObjectStore
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// RegisterGitTools registers the 5 git-related workspace tools.
|
||||
// Only meaningful when a workspace has git_remote_url set — the completion
|
||||
// handler filters them out otherwise (via GitToolNames).
|
||||
// Each declares RequireWorkspace + DenyVisitor availability via workspaceToolBase.
|
||||
func RegisterGitTools(gitOps *workspace.GitOps) {
|
||||
Register(&gitStatusTool{gitOps: gitOps})
|
||||
Register(&gitDiffTool{gitOps: gitOps})
|
||||
@@ -19,14 +18,12 @@ func RegisterGitTools(gitOps *workspace.GitOps) {
|
||||
Register(&gitBranchTool{gitOps: gitOps})
|
||||
}
|
||||
|
||||
// GitToolNames returns the names of all git tools for filtering.
|
||||
func GitToolNames() []string {
|
||||
return []string{"git_status", "git_diff", "git_commit", "git_log", "git_branch"}
|
||||
}
|
||||
|
||||
// ── git_status ───────────────────────────────
|
||||
|
||||
type gitStatusTool struct{ gitOps *workspace.GitOps }
|
||||
type gitStatusTool struct {
|
||||
workspaceToolBase
|
||||
gitOps *workspace.GitOps
|
||||
}
|
||||
|
||||
func (t *gitStatusTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
@@ -53,7 +50,10 @@ func (t *gitStatusTool) Execute(ctx context.Context, execCtx ExecutionContext, a
|
||||
|
||||
// ── git_diff ─────────────────────────────────
|
||||
|
||||
type gitDiffTool struct{ gitOps *workspace.GitOps }
|
||||
type gitDiffTool struct {
|
||||
workspaceToolBase
|
||||
gitOps *workspace.GitOps
|
||||
}
|
||||
|
||||
func (t *gitDiffTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
@@ -92,7 +92,10 @@ func (t *gitDiffTool) Execute(ctx context.Context, execCtx ExecutionContext, arg
|
||||
|
||||
// ── git_commit ───────────────────────────────
|
||||
|
||||
type gitCommitTool struct{ gitOps *workspace.GitOps }
|
||||
type gitCommitTool struct {
|
||||
workspaceToolBase
|
||||
gitOps *workspace.GitOps
|
||||
}
|
||||
|
||||
func (t *gitCommitTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
@@ -131,7 +134,10 @@ func (t *gitCommitTool) Execute(ctx context.Context, execCtx ExecutionContext, a
|
||||
|
||||
// ── git_log ──────────────────────────────────
|
||||
|
||||
type gitLogTool struct{ gitOps *workspace.GitOps }
|
||||
type gitLogTool struct {
|
||||
workspaceToolBase
|
||||
gitOps *workspace.GitOps
|
||||
}
|
||||
|
||||
func (t *gitLogTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
@@ -171,7 +177,10 @@ func (t *gitLogTool) Execute(ctx context.Context, execCtx ExecutionContext, args
|
||||
|
||||
// ── git_branch ───────────────────────────────
|
||||
|
||||
type gitBranchTool struct{ gitOps *workspace.GitOps }
|
||||
type gitBranchTool struct {
|
||||
workspaceToolBase
|
||||
gitOps *workspace.GitOps
|
||||
}
|
||||
|
||||
func (t *gitBranchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
@@ -28,6 +28,7 @@ func RegisterKBSearch(stores store.Stores, embedder *knowledge.Embedder) {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type kbSearchTool struct {
|
||||
BaseTool
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ func RegisterMemoryTools(stores store.Stores, embedder *knowledge.Embedder) {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type memorySaveTool struct {
|
||||
visitorDeniedBase
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
@@ -147,6 +148,7 @@ func (t *memorySaveTool) embedMemory(ctx context.Context, m *models.Memory, user
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type memoryRecallTool struct {
|
||||
visitorDeniedBase
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ func vectorToString(vec []float64) string {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type noteCreateTool struct {
|
||||
visitorDeniedBase
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
@@ -168,6 +169,7 @@ func (t *noteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type noteSearchTool struct {
|
||||
visitorDeniedBase
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
@@ -347,6 +349,7 @@ type noteSearchResult struct {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type noteUpdateTool struct {
|
||||
visitorDeniedBase
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
@@ -445,7 +448,7 @@ func (t *noteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
// note_list
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type noteListTool struct{}
|
||||
type noteListTool struct{ visitorDeniedBase }
|
||||
|
||||
func (t *noteListTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
85
server/tools/predicates.go
Normal file
85
server/tools/predicates.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package tools
|
||||
|
||||
// ── Built-in Predicates (v0.25.0) ──────────
|
||||
//
|
||||
// Predicates determine tool availability based on runtime context.
|
||||
// Tools declare their availability by returning a Require from
|
||||
// Availability(). The registry evaluates predicates in AvailableFor()
|
||||
// before sending tool definitions to the LLM.
|
||||
//
|
||||
// AlwaysAvailable is defined in types.go (BaseTool references it).
|
||||
|
||||
// RequireWorkspace returns true when a workspace is bound to the
|
||||
// channel. Workspace and git tools use this.
|
||||
func RequireWorkspace(tc ToolContext) bool {
|
||||
return tc.WorkspaceID != ""
|
||||
}
|
||||
|
||||
// RequireWorkflow returns true when the channel is a workflow instance.
|
||||
// Workflow-specific tools (e.g. workflow_advance) use this.
|
||||
func RequireWorkflow(tc ToolContext) bool {
|
||||
return tc.WorkflowID != ""
|
||||
}
|
||||
|
||||
// RequireTeam returns true when the channel belongs to a team.
|
||||
func RequireTeam(tc ToolContext) bool {
|
||||
return tc.TeamID != ""
|
||||
}
|
||||
|
||||
// DenyVisitor returns true for authenticated users, false for anonymous
|
||||
// session participants (v0.24.3). Tools that expose personal data
|
||||
// (memory, notes) or perform privileged operations use this.
|
||||
func DenyVisitor(tc ToolContext) bool {
|
||||
return !tc.IsVisitor
|
||||
}
|
||||
|
||||
// All combines multiple predicates — all must pass for the tool to be
|
||||
// available. Short-circuits on first failure.
|
||||
func All(reqs ...Require) Require {
|
||||
return func(tc ToolContext) bool {
|
||||
for _, r := range reqs {
|
||||
if !r(tc) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Any combines multiple predicates — at least one must pass.
|
||||
// Short-circuits on first success.
|
||||
func Any(reqs ...Require) Require {
|
||||
return func(tc ToolContext) bool {
|
||||
for _, r := range reqs {
|
||||
if r(tc) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Not inverts a predicate.
|
||||
func Not(req Require) Require {
|
||||
return func(tc ToolContext) bool {
|
||||
return !req(tc)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared Base Types ───────────────────────
|
||||
// Embedded in tool structs to provide common availability predicates
|
||||
// without repeating Availability() overrides on every struct.
|
||||
|
||||
// workspaceToolBase provides RequireWorkspace + DenyVisitor availability.
|
||||
// Embed in workspace and git tool structs.
|
||||
type workspaceToolBase struct{ BaseTool }
|
||||
|
||||
var _requireWorkspaceAndDenyVisitor = All(RequireWorkspace, DenyVisitor)
|
||||
|
||||
func (workspaceToolBase) Availability() Require { return _requireWorkspaceAndDenyVisitor }
|
||||
|
||||
// visitorDeniedBase provides DenyVisitor availability.
|
||||
// Embed in memory and notes tool structs.
|
||||
type visitorDeniedBase struct{ BaseTool }
|
||||
|
||||
func (visitorDeniedBase) Availability() Require { return DenyVisitor }
|
||||
376
server/tools/predicates_test.go
Normal file
376
server/tools/predicates_test.go
Normal file
@@ -0,0 +1,376 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Predicate Tests ─────────────────────────
|
||||
|
||||
func TestAlwaysAvailable(t *testing.T) {
|
||||
cases := []ToolContext{
|
||||
{},
|
||||
{ChannelType: "direct"},
|
||||
{IsVisitor: true},
|
||||
{WorkspaceID: "ws-1", WorkflowID: "wf-1", TeamID: "t-1"},
|
||||
}
|
||||
for i, tc := range cases {
|
||||
if !AlwaysAvailable(tc) {
|
||||
t.Errorf("case %d: AlwaysAvailable returned false", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireWorkspace(t *testing.T) {
|
||||
if RequireWorkspace(ToolContext{}) {
|
||||
t.Error("Expected false when WorkspaceID is empty")
|
||||
}
|
||||
if !RequireWorkspace(ToolContext{WorkspaceID: "ws-123"}) {
|
||||
t.Error("Expected true when WorkspaceID is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireWorkflow(t *testing.T) {
|
||||
if RequireWorkflow(ToolContext{}) {
|
||||
t.Error("Expected false when WorkflowID is empty")
|
||||
}
|
||||
if !RequireWorkflow(ToolContext{WorkflowID: "wf-456"}) {
|
||||
t.Error("Expected true when WorkflowID is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireTeam(t *testing.T) {
|
||||
if RequireTeam(ToolContext{}) {
|
||||
t.Error("Expected false when TeamID is empty")
|
||||
}
|
||||
if !RequireTeam(ToolContext{TeamID: "team-1"}) {
|
||||
t.Error("Expected true when TeamID is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenyVisitor(t *testing.T) {
|
||||
if !DenyVisitor(ToolContext{IsVisitor: false}) {
|
||||
t.Error("Expected true for authenticated user")
|
||||
}
|
||||
if DenyVisitor(ToolContext{IsVisitor: true}) {
|
||||
t.Error("Expected false for visitor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAll(t *testing.T) {
|
||||
wsAndNonVisitor := All(RequireWorkspace, DenyVisitor)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tc ToolContext
|
||||
expect bool
|
||||
}{
|
||||
{"both satisfied", ToolContext{WorkspaceID: "ws-1", IsVisitor: false}, true},
|
||||
{"no workspace", ToolContext{WorkspaceID: "", IsVisitor: false}, false},
|
||||
{"is visitor", ToolContext{WorkspaceID: "ws-1", IsVisitor: true}, false},
|
||||
{"neither satisfied", ToolContext{WorkspaceID: "", IsVisitor: true}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := wsAndNonVisitor(tc.tc); got != tc.expect {
|
||||
t.Errorf("got %v, want %v", got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllEmpty(t *testing.T) {
|
||||
// All() with no predicates should pass (vacuous truth)
|
||||
pred := All()
|
||||
if !pred(ToolContext{}) {
|
||||
t.Error("All() with no predicates should return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAny(t *testing.T) {
|
||||
wsOrWorkflow := Any(RequireWorkspace, RequireWorkflow)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tc ToolContext
|
||||
expect bool
|
||||
}{
|
||||
{"workspace only", ToolContext{WorkspaceID: "ws-1"}, true},
|
||||
{"workflow only", ToolContext{WorkflowID: "wf-1"}, true},
|
||||
{"both", ToolContext{WorkspaceID: "ws-1", WorkflowID: "wf-1"}, true},
|
||||
{"neither", ToolContext{}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := wsOrWorkflow(tc.tc); got != tc.expect {
|
||||
t.Errorf("got %v, want %v", got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyEmpty(t *testing.T) {
|
||||
// Any() with no predicates should fail (no match)
|
||||
pred := Any()
|
||||
if pred(ToolContext{}) {
|
||||
t.Error("Any() with no predicates should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNot(t *testing.T) {
|
||||
notVisitor := Not(func(tc ToolContext) bool { return tc.IsVisitor })
|
||||
if !notVisitor(ToolContext{IsVisitor: false}) {
|
||||
t.Error("Not(visitor) should return true for non-visitor")
|
||||
}
|
||||
if notVisitor(ToolContext{IsVisitor: true}) {
|
||||
t.Error("Not(visitor) should return false for visitor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposedPredicates(t *testing.T) {
|
||||
// workspace_create: available when NO workspace AND not visitor
|
||||
workspaceCreate := All(Not(RequireWorkspace), DenyVisitor)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tc ToolContext
|
||||
expect bool
|
||||
}{
|
||||
{"no ws, authenticated", ToolContext{}, true},
|
||||
{"has ws, authenticated", ToolContext{WorkspaceID: "ws-1"}, false},
|
||||
{"no ws, visitor", ToolContext{IsVisitor: true}, false},
|
||||
{"has ws, visitor", ToolContext{WorkspaceID: "ws-1", IsVisitor: true}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := workspaceCreate(tc.tc); got != tc.expect {
|
||||
t.Errorf("got %v, want %v", got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── BaseTool Tests ──────────────────────────
|
||||
|
||||
type testToolWithBase struct {
|
||||
BaseTool
|
||||
}
|
||||
|
||||
func (t *testToolWithBase) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "always_tool",
|
||||
Description: "test tool with BaseTool embed",
|
||||
Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *testToolWithBase) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) {
|
||||
return `{"ok":true}`, nil
|
||||
}
|
||||
|
||||
func TestBaseToolSatisfiesContextualTool(t *testing.T) {
|
||||
tool := &testToolWithBase{}
|
||||
|
||||
// Must satisfy ContextualTool
|
||||
ct, ok := interface{}(tool).(ContextualTool)
|
||||
if !ok {
|
||||
t.Fatal("testToolWithBase should satisfy ContextualTool interface")
|
||||
}
|
||||
|
||||
// BaseTool.Availability() should return AlwaysAvailable
|
||||
if !ct.Availability()(ToolContext{IsVisitor: true}) {
|
||||
t.Error("BaseTool.Availability() should be AlwaysAvailable")
|
||||
}
|
||||
}
|
||||
|
||||
// ── AvailableFor Tests ──────────────────────
|
||||
|
||||
// testContextTool overrides availability with a custom predicate.
|
||||
type testContextTool struct {
|
||||
BaseTool
|
||||
name string
|
||||
req Require
|
||||
}
|
||||
|
||||
func (t *testContextTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: t.name,
|
||||
Description: "test contextual tool: " + t.name,
|
||||
Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *testContextTool) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) {
|
||||
return `{"ok":true}`, nil
|
||||
}
|
||||
|
||||
func (t *testContextTool) Availability() Require {
|
||||
return t.req
|
||||
}
|
||||
|
||||
// testPlainTool is a tool that does NOT implement ContextualTool —
|
||||
// only the base Tool interface. It should always be included.
|
||||
type testPlainTool struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (t *testPlainTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: t.name,
|
||||
Description: "plain tool: " + t.name,
|
||||
Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *testPlainTool) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) {
|
||||
return `{"ok":true}`, nil
|
||||
}
|
||||
|
||||
func TestAvailableFor(t *testing.T) {
|
||||
// Save and restore global registry
|
||||
origRegistry := registry
|
||||
defer func() { registry = origRegistry }()
|
||||
|
||||
registry = map[string]Tool{
|
||||
"always_tool": &testToolWithBase{},
|
||||
"ws_tool": &testContextTool{name: "ws_tool", req: RequireWorkspace},
|
||||
"visitor_denied": &testContextTool{name: "visitor_denied", req: DenyVisitor},
|
||||
"ws_no_visitor": &testContextTool{name: "ws_no_visitor", req: All(RequireWorkspace, DenyVisitor)},
|
||||
"plain_tool": &testPlainTool{name: "plain_tool"},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tctx ToolContext
|
||||
disabled map[string]bool
|
||||
expect []string // expected tool names (sorted doesn't matter, just set membership)
|
||||
}{
|
||||
{
|
||||
name: "empty context — only always + plain",
|
||||
tctx: ToolContext{},
|
||||
expect: []string{"always_tool", "visitor_denied", "plain_tool"},
|
||||
},
|
||||
{
|
||||
name: "workspace context — ws tools available",
|
||||
tctx: ToolContext{WorkspaceID: "ws-1"},
|
||||
expect: []string{"always_tool", "ws_tool", "visitor_denied", "ws_no_visitor", "plain_tool"},
|
||||
},
|
||||
{
|
||||
name: "visitor with workspace — visitor_denied and ws_no_visitor excluded",
|
||||
tctx: ToolContext{WorkspaceID: "ws-1", IsVisitor: true},
|
||||
expect: []string{"always_tool", "ws_tool", "plain_tool"},
|
||||
},
|
||||
{
|
||||
name: "disabled tool excluded",
|
||||
tctx: ToolContext{WorkspaceID: "ws-1"},
|
||||
disabled: map[string]bool{"ws_tool": true},
|
||||
expect: []string{"always_tool", "visitor_denied", "ws_no_visitor", "plain_tool"},
|
||||
},
|
||||
{
|
||||
name: "visitor no workspace — minimal set",
|
||||
tctx: ToolContext{IsVisitor: true},
|
||||
expect: []string{"always_tool", "plain_tool"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
disabled := tc.disabled
|
||||
if disabled == nil {
|
||||
disabled = map[string]bool{}
|
||||
}
|
||||
defs := AvailableFor(tc.tctx, disabled)
|
||||
|
||||
got := make(map[string]bool, len(defs))
|
||||
for _, d := range defs {
|
||||
got[d.Name] = true
|
||||
}
|
||||
|
||||
// Check expected tools are present
|
||||
for _, name := range tc.expect {
|
||||
if !got[name] {
|
||||
t.Errorf("expected tool %q not found in result", name)
|
||||
}
|
||||
}
|
||||
// Check no unexpected tools
|
||||
expect := make(map[string]bool, len(tc.expect))
|
||||
for _, name := range tc.expect {
|
||||
expect[name] = true
|
||||
}
|
||||
for name := range got {
|
||||
if !expect[name] {
|
||||
t.Errorf("unexpected tool %q in result", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableForBackwardCompat(t *testing.T) {
|
||||
// AvailableFor with empty ToolContext should behave like
|
||||
// the old AllDefinitionsFiltered for plain tools
|
||||
origRegistry := registry
|
||||
defer func() { registry = origRegistry }()
|
||||
|
||||
registry = map[string]Tool{
|
||||
"tool_a": &testPlainTool{name: "tool_a"},
|
||||
"tool_b": &testPlainTool{name: "tool_b"},
|
||||
"tool_c": &testPlainTool{name: "tool_c"},
|
||||
}
|
||||
|
||||
// No disabled, empty context — all plain tools should appear
|
||||
defs := AvailableFor(ToolContext{}, map[string]bool{})
|
||||
if len(defs) != 3 {
|
||||
t.Errorf("Expected 3 tools, got %d", len(defs))
|
||||
}
|
||||
|
||||
// Disable one
|
||||
defs = AvailableFor(ToolContext{}, map[string]bool{"tool_b": true})
|
||||
if len(defs) != 2 {
|
||||
t.Errorf("Expected 2 tools, got %d", len(defs))
|
||||
}
|
||||
for _, d := range defs {
|
||||
if d.Name == "tool_b" {
|
||||
t.Error("tool_b should be filtered out")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ToolContext from ExecutionContext ────────
|
||||
|
||||
func TestToolContextFromExecContext(t *testing.T) {
|
||||
// Verify the fields map correctly between the two structs.
|
||||
// This is a compile-time/structural test — when ToolContext is
|
||||
// built from ExecutionContext in the completion handler, these
|
||||
// fields must align.
|
||||
exec := ExecutionContext{
|
||||
UserID: "u-1",
|
||||
ChannelID: "ch-1",
|
||||
PersonaID: "p-1",
|
||||
WorkspaceID: "ws-1",
|
||||
WorkflowID: "wf-1",
|
||||
TeamID: "t-1",
|
||||
}
|
||||
|
||||
// Simulate what the completion handler will do
|
||||
tc := ToolContext{
|
||||
WorkspaceID: exec.WorkspaceID,
|
||||
WorkflowID: exec.WorkflowID,
|
||||
TeamID: exec.TeamID,
|
||||
PersonaID: exec.PersonaID,
|
||||
// ChannelType and IsVisitor come from channel record / gin context
|
||||
}
|
||||
|
||||
if tc.WorkspaceID != "ws-1" {
|
||||
t.Errorf("WorkspaceID mismatch: %q", tc.WorkspaceID)
|
||||
}
|
||||
if tc.WorkflowID != "wf-1" {
|
||||
t.Errorf("WorkflowID mismatch: %q", tc.WorkflowID)
|
||||
}
|
||||
if tc.TeamID != "t-1" {
|
||||
t.Errorf("TeamID mismatch: %q", tc.TeamID)
|
||||
}
|
||||
if tc.PersonaID != "p-1" {
|
||||
t.Errorf("PersonaID mismatch: %q", tc.PersonaID)
|
||||
}
|
||||
}
|
||||
@@ -38,16 +38,31 @@ func AllDefinitions() []ToolDef {
|
||||
|
||||
// AllDefinitionsFiltered returns tool definitions excluding any names in the
|
||||
// disabled set. Used when the frontend sends a disabled_tools list.
|
||||
//
|
||||
// Deprecated: use AvailableFor() for context-aware filtering. This wrapper
|
||||
// remains for call sites that don't yet have a ToolContext.
|
||||
func AllDefinitionsFiltered(disabled map[string]bool) []ToolDef {
|
||||
if len(disabled) == 0 {
|
||||
return AllDefinitions()
|
||||
}
|
||||
return AvailableFor(ToolContext{}, disabled)
|
||||
}
|
||||
|
||||
// AvailableFor returns tool definitions available in the given context,
|
||||
// excluding any names in the disabled set. Tools that implement
|
||||
// ContextualTool have their Availability() predicate evaluated against
|
||||
// tctx. Tools that only implement Tool (no predicate) are always included.
|
||||
func AvailableFor(tctx ToolContext, disabled map[string]bool) []ToolDef {
|
||||
defs := make([]ToolDef, 0, len(registry))
|
||||
for _, t := range registry {
|
||||
def := t.Definition()
|
||||
if !disabled[def.Name] {
|
||||
defs = append(defs, def)
|
||||
if disabled[def.Name] {
|
||||
continue
|
||||
}
|
||||
// Check context-aware availability if the tool declares it
|
||||
if ct, ok := t.(ContextualTool); ok {
|
||||
if !ct.Availability()(tctx) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
defs = append(defs, def)
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
@@ -39,12 +39,16 @@ type ToolResult struct {
|
||||
|
||||
// ── Tool Interface ──────────────────────────
|
||||
|
||||
// ExecutionContext provides tool implementations with the info they need.
|
||||
// ExecutionContext provides tool implementations with the info they need
|
||||
// at execution time. Populated from channel/request state in the
|
||||
// completion handler.
|
||||
type ExecutionContext struct {
|
||||
UserID string
|
||||
ChannelID string
|
||||
PersonaID string // set when a persona is active — tools use for scoped KB access
|
||||
WorkspaceID string // set when channel/project has a workspace bound
|
||||
WorkflowID string // v0.25.0: set when channel is a workflow instance
|
||||
TeamID string // v0.25.0: set when channel belongs to a team
|
||||
}
|
||||
|
||||
// Tool is the interface every built-in tool implements.
|
||||
@@ -57,6 +61,46 @@ type Tool interface {
|
||||
Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error)
|
||||
}
|
||||
|
||||
// ── Context-Aware Tool System (v0.25.0) ────
|
||||
|
||||
// ToolContext describes the runtime environment for tool availability
|
||||
// checks. Built from channel metadata in the completion handler —
|
||||
// separate from ExecutionContext because availability is checked before
|
||||
// execution.
|
||||
type ToolContext struct {
|
||||
ChannelType string // "direct", "dm", "group", "channel", "workflow"
|
||||
WorkspaceID string // non-empty when channel/project has a workspace
|
||||
WorkflowID string // non-empty when channel is a workflow instance
|
||||
TeamID string // non-empty when channel belongs to a team
|
||||
PersonaID string // non-empty when a persona is active
|
||||
IsVisitor bool // true for session_participants (v0.24.3)
|
||||
}
|
||||
|
||||
// Require is a predicate that determines if a tool is available in a
|
||||
// given context. Return true = tool is available, false = suppressed.
|
||||
type Require func(ToolContext) bool
|
||||
|
||||
// ContextualTool extends Tool with an availability predicate. Tools
|
||||
// that embed BaseTool get AlwaysAvailable by default. Tools that need
|
||||
// context-dependent visibility override Availability().
|
||||
type ContextualTool interface {
|
||||
Tool
|
||||
Availability() Require
|
||||
}
|
||||
|
||||
// BaseTool provides default availability (always available). Embed in
|
||||
// any tool struct for backward compat — no behavioral change.
|
||||
type BaseTool struct{}
|
||||
|
||||
// Availability returns AlwaysAvailable. Override in tool structs that
|
||||
// need context-dependent visibility.
|
||||
func (BaseTool) Availability() Require { return AlwaysAvailable }
|
||||
|
||||
// AlwaysAvailable is the default predicate — tool is available in
|
||||
// every context. Defined here (not in predicates.go) because BaseTool
|
||||
// references it.
|
||||
func AlwaysAvailable(_ ToolContext) bool { return true }
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
// MustJSON marshals v to a json.RawMessage, panicking on error.
|
||||
|
||||
@@ -22,7 +22,7 @@ func init() {
|
||||
// url_fetch
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type URLFetchTool struct{}
|
||||
type URLFetchTool struct{ BaseTool }
|
||||
|
||||
func (t *URLFetchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
@@ -16,7 +16,7 @@ func init() {
|
||||
// web_search
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type WebSearchTool struct{}
|
||||
type WebSearchTool struct{ BaseTool }
|
||||
|
||||
func (t *WebSearchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
@@ -34,16 +34,6 @@ func RegisterWorkspaceSearchTool(stores store.Stores, embedder *knowledge.Embedd
|
||||
Register(&workspaceSearchTool{stores: stores, embedder: embedder})
|
||||
}
|
||||
|
||||
// WorkspaceToolNames returns the names of all workspace tools.
|
||||
// Used by the completion handler to filter them out when no workspace is bound.
|
||||
func WorkspaceToolNames() []string {
|
||||
return []string{
|
||||
"workspace_ls", "workspace_read", "workspace_write",
|
||||
"workspace_rm", "workspace_mv", "workspace_patch",
|
||||
"workspace_search",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared ─────────────────────────────────
|
||||
|
||||
// loadWorkspace resolves the workspace from the execution context.
|
||||
@@ -70,6 +60,7 @@ const (
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type workspaceLsTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
wfs *workspace.FS
|
||||
}
|
||||
@@ -136,6 +127,7 @@ func (t *workspaceLsTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type workspaceReadTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
wfs *workspace.FS
|
||||
}
|
||||
@@ -242,6 +234,7 @@ func isTextContentType(ct string) bool {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type workspaceWriteTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
wfs *workspace.FS
|
||||
}
|
||||
@@ -296,6 +289,7 @@ func (t *workspaceWriteTool) Execute(ctx context.Context, execCtx ExecutionConte
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type workspaceRmTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
wfs *workspace.FS
|
||||
}
|
||||
@@ -348,6 +342,7 @@ func (t *workspaceRmTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type workspaceMvTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
wfs *workspace.FS
|
||||
}
|
||||
@@ -401,6 +396,7 @@ func (t *workspaceMvTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type workspacePatchTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
wfs *workspace.FS
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
// ── workspace_search ────────────────────────
|
||||
|
||||
type workspaceSearchTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user