Changeset 0.25.0 (#160)
This commit is contained in:
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user