Changeset 0.11.0 (#62)
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
# Chat Switchboard - Backend Dockerfile
|
||||
# ==========================================
|
||||
# Standalone Go API server for cluster deployment.
|
||||
# Build context is ./server (not repo root).
|
||||
# Build context is the repo root (not ./server).
|
||||
# ==========================================
|
||||
|
||||
# Build stage
|
||||
@@ -13,11 +13,11 @@ ARG APP_VERSION=dev
|
||||
WORKDIR /app
|
||||
|
||||
# Copy module files and download deps
|
||||
COPY go.mod go.sum* ./
|
||||
COPY server/go.mod server/go.sum* ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source and tidy (resolves any go.sum gaps)
|
||||
COPY . .
|
||||
COPY server/ .
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=${APP_VERSION}" -o /bin/switchboard .
|
||||
|
||||
@@ -31,6 +31,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
COPY --from=builder /bin/switchboard /usr/local/bin/switchboard
|
||||
COPY --from=builder /app/database/migrations /app/database/migrations
|
||||
|
||||
# Builtin extensions (seeded into DB on startup)
|
||||
COPY extensions/builtin/ /app/extensions/builtin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
41
server/database/migrations/006_extensions.sql
Normal file
41
server/database/migrations/006_extensions.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- Migration 006: Extension system tables
|
||||
--
|
||||
-- Extensions are installable plugins that add renderers, tools, surfaces,
|
||||
-- and event handlers. Three tiers: browser (JS), starlark (sandbox),
|
||||
-- sidecar (container). Browser tier ships first (v0.11.0).
|
||||
--
|
||||
-- See EXTENSIONS.md for full spec.
|
||||
|
||||
-- ── Extension registry ──────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ext_id VARCHAR(100) NOT NULL UNIQUE, -- manifest id (e.g. "collapsible-code")
|
||||
name VARCHAR(200) NOT NULL, -- human label
|
||||
version VARCHAR(50) NOT NULL DEFAULT '0.0.0',
|
||||
tier VARCHAR(20) NOT NULL DEFAULT 'browser', -- browser | starlark | sidecar
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
author VARCHAR(200) NOT NULL DEFAULT '',
|
||||
manifest JSONB NOT NULL DEFAULT '{}', -- full manifest JSON
|
||||
is_system BOOLEAN NOT NULL DEFAULT false, -- admin-pushed, users can't disable
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true, -- global enable/disable
|
||||
scope VARCHAR(20) NOT NULL DEFAULT 'global', -- global | team | personal
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
installed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- For bundled extensions that ship with core
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier);
|
||||
CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled) WHERE is_enabled = true;
|
||||
|
||||
-- ── Per-user extension settings ─────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extension_user_settings (
|
||||
extension_id UUID NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
settings JSONB NOT NULL DEFAULT '{}', -- user's config values
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true, -- per-user toggle
|
||||
PRIMARY KEY (extension_id, user_id)
|
||||
);
|
||||
@@ -3,6 +3,7 @@ package events
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bus is a labeled publish/subscribe event bus.
|
||||
@@ -114,3 +115,28 @@ func match(label, pattern string) bool {
|
||||
prefix := pattern[:len(pattern)-1] // "chat.message." from "chat.message.*"
|
||||
return strings.HasPrefix(label, prefix)
|
||||
}
|
||||
|
||||
// WaitFor subscribes to an exact label and blocks until an event arrives
|
||||
// or the timeout elapses. Returns the event and true, or a zero Event and
|
||||
// false on timeout. The subscription is automatically removed after.
|
||||
//
|
||||
// Used by the browser tool bridge: the server publishes tool.call.{id}
|
||||
// to the client and then calls WaitFor("tool.result.{id}", 30s) to block
|
||||
// until the browser returns the result.
|
||||
func (b *Bus) WaitFor(label string, timeout time.Duration) (Event, bool) {
|
||||
ch := make(chan Event, 1)
|
||||
unsub := b.Subscribe(label, func(e Event) {
|
||||
select {
|
||||
case ch <- e:
|
||||
default:
|
||||
}
|
||||
})
|
||||
defer unsub()
|
||||
|
||||
select {
|
||||
case e := <-ch:
|
||||
return e, true
|
||||
case <-time.After(timeout):
|
||||
return Event{}, false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
@@ -112,6 +113,12 @@ func TestRouteFor(t *testing.T) {
|
||||
{"unknown.event", DirLocal}, // default
|
||||
{"ping", DirFromClient},
|
||||
{"pong", DirToClient},
|
||||
// Tool bridge routes
|
||||
{"tool.call.abc123", DirToClient},
|
||||
{"tool.result.abc123", DirFromClient},
|
||||
// Extension lifecycle
|
||||
{"extension.loaded", DirLocal},
|
||||
{"extension.error", DirLocal},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -121,3 +128,84 @@ func TestRouteFor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitFor_Success(t *testing.T) {
|
||||
bus := NewBus()
|
||||
|
||||
// Publish after a short delay
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
bus.Publish(Event{
|
||||
Label: "tool.result.abc123",
|
||||
Payload: json.RawMessage(`{"result":"hello"}`),
|
||||
})
|
||||
}()
|
||||
|
||||
event, ok := bus.WaitFor("tool.result.abc123", 1*time.Second)
|
||||
if !ok {
|
||||
t.Fatal("WaitFor timed out, expected success")
|
||||
}
|
||||
if event.Label != "tool.result.abc123" {
|
||||
t.Errorf("expected label tool.result.abc123, got %s", event.Label)
|
||||
}
|
||||
if string(event.Payload) != `{"result":"hello"}` {
|
||||
t.Errorf("unexpected payload: %s", string(event.Payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitFor_Timeout(t *testing.T) {
|
||||
bus := NewBus()
|
||||
|
||||
_, ok := bus.WaitFor("tool.result.never", 50*time.Millisecond)
|
||||
if ok {
|
||||
t.Error("WaitFor should have timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitFor_IgnoresOtherLabels(t *testing.T) {
|
||||
bus := NewBus()
|
||||
|
||||
go func() {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
bus.Publish(Event{Label: "tool.result.other", Payload: json.RawMessage(`{}`)})
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
bus.Publish(Event{Label: "tool.result.target", Payload: json.RawMessage(`{"ok":true}`)})
|
||||
}()
|
||||
|
||||
event, ok := bus.WaitFor("tool.result.target", 1*time.Second)
|
||||
if !ok {
|
||||
t.Fatal("WaitFor timed out")
|
||||
}
|
||||
if event.Label != "tool.result.target" {
|
||||
t.Errorf("got wrong label: %s", event.Label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitFor_CleansUpSubscription(t *testing.T) {
|
||||
bus := NewBus()
|
||||
|
||||
// WaitFor with immediate timeout
|
||||
bus.WaitFor("tool.result.cleanup", 1*time.Millisecond)
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
// Verify no panic when publishing to the label after cleanup
|
||||
bus.Publish(Event{Label: "tool.result.cleanup", Payload: json.RawMessage(`{}`)})
|
||||
}
|
||||
|
||||
func TestToolCallRouteToClient(t *testing.T) {
|
||||
if !ShouldSendToClient("tool.call.abc123") {
|
||||
t.Error("tool.call.* should be sent to client")
|
||||
}
|
||||
if ShouldAcceptFromClient("tool.call.abc123") {
|
||||
t.Error("tool.call.* should NOT be accepted from client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultRouteFromClient(t *testing.T) {
|
||||
if !ShouldAcceptFromClient("tool.result.abc123") {
|
||||
t.Error("tool.result.* should be accepted from client")
|
||||
}
|
||||
if ShouldSendToClient("tool.result.abc123") {
|
||||
t.Error("tool.result.* should NOT be sent to client")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,14 @@ var routeTable = map[string]Direction{
|
||||
"internal.": DirLocal,
|
||||
"db.": DirLocal,
|
||||
|
||||
// Tool execution (browser tools bridge)
|
||||
"tool.call.": DirToClient, // Server → specific client (browser tool invocation)
|
||||
"tool.result.": DirFromClient, // Client → server (browser tool result)
|
||||
|
||||
// Extension lifecycle
|
||||
"extension.loaded": DirLocal, // Client-only
|
||||
"extension.error": DirLocal,
|
||||
|
||||
// Heartbeat
|
||||
"ping": DirFromClient,
|
||||
"pong": DirToClient,
|
||||
|
||||
@@ -118,6 +118,42 @@ func (h *Hub) ConnsByUser(userID string) []*Conn {
|
||||
return result
|
||||
}
|
||||
|
||||
// IsConnected returns true if the user has at least one active WebSocket.
|
||||
func (h *Hub) IsConnected(userID string) bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for _, c := range h.conns {
|
||||
if c.userID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SendToUser sends an event directly to all WebSocket connections for a user,
|
||||
// bypassing room filtering. Used for targeted tool.call delivery.
|
||||
func (h *Hub) SendToUser(userID string, event Event) {
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for _, c := range h.conns {
|
||||
if c.userID == userID {
|
||||
select {
|
||||
case c.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetBus returns the underlying event bus.
|
||||
func (h *Hub) GetBus() *Bus {
|
||||
return h.bus
|
||||
}
|
||||
|
||||
// removeConn cleans up a connection.
|
||||
func (h *Hub) removeConn(conn *Conn) {
|
||||
h.mu.Lock()
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
@@ -39,11 +40,12 @@ type completionRequest struct {
|
||||
type CompletionHandler struct {
|
||||
vault *crypto.KeyResolver
|
||||
stores store.Stores
|
||||
hub *events.Hub // WebSocket hub for browser tool bridge
|
||||
}
|
||||
|
||||
// NewCompletionHandler creates a new handler.
|
||||
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores) *CompletionHandler {
|
||||
return &CompletionHandler{vault: vault, stores: stores}
|
||||
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *CompletionHandler {
|
||||
return &CompletionHandler{vault: vault, stores: stores, hub: hub}
|
||||
}
|
||||
|
||||
// ── Chat Completion ─────────────────────────
|
||||
@@ -171,8 +173,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Attach tool definitions if model supports tool calling and tools are available
|
||||
if caps.ToolCalling && tools.HasTools() {
|
||||
provReq.Tools = h.buildToolDefs()
|
||||
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
||||
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
||||
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools)
|
||||
}
|
||||
|
||||
// Determine streaming
|
||||
@@ -189,19 +192,56 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
// buildToolDefs converts registered tools to provider-format tool definitions.
|
||||
func (h *CompletionHandler) buildToolDefs() []providers.ToolDef {
|
||||
// If includeBrowser is true, also fetches tool schemas from enabled browser extensions.
|
||||
func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool) []providers.ToolDef {
|
||||
allTools := tools.AllDefinitions()
|
||||
defs := make([]providers.ToolDef, len(allTools))
|
||||
for i, t := range allTools {
|
||||
defs[i] = providers.ToolDef{
|
||||
defs := make([]providers.ToolDef, 0, len(allTools))
|
||||
for _, t := range allTools {
|
||||
defs = append(defs, providers.ToolDef{
|
||||
Type: "function",
|
||||
Function: providers.FunctionDef{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Append browser-defined tool schemas from extensions
|
||||
if includeBrowser {
|
||||
exts, err := h.stores.Extensions.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to load extensions for tools: %v", err)
|
||||
return defs
|
||||
}
|
||||
for _, ext := range exts {
|
||||
if ext.Tier != "browser" {
|
||||
continue
|
||||
}
|
||||
var manifest struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters json.RawMessage `json:"parameters"`
|
||||
Tier string `json:"tier"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, t := range manifest.Tools {
|
||||
defs = append(defs, providers.ToolDef{
|
||||
Type: "function",
|
||||
Function: providers.FunctionDef{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return defs
|
||||
}
|
||||
|
||||
@@ -216,7 +256,7 @@ func (h *CompletionHandler) streamCompletion(
|
||||
req providers.CompletionRequest,
|
||||
channelID, userID, model, configID, providerScope string,
|
||||
) {
|
||||
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID)
|
||||
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, h.hub)
|
||||
|
||||
// Persist assistant response
|
||||
if result.Content != "" {
|
||||
|
||||
346
server/handlers/extensions.go
Normal file
346
server/handlers/extensions.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ExtensionHandler serves extension management endpoints.
|
||||
type ExtensionHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
func NewExtensionHandler(stores store.Stores) *ExtensionHandler {
|
||||
return &ExtensionHandler{stores: stores}
|
||||
}
|
||||
|
||||
// ── User endpoints ──────────────────────────────
|
||||
|
||||
// ListUserExtensions returns all enabled extensions for the current user,
|
||||
// with user-specific settings and enabled state merged in.
|
||||
// GET /api/v1/extensions
|
||||
func (h *ExtensionHandler) ListUserExtensions(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
tier := c.Query("tier") // optional filter
|
||||
|
||||
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to list extensions"})
|
||||
return
|
||||
}
|
||||
|
||||
if tier != "" {
|
||||
filtered := make([]models.UserExtension, 0)
|
||||
for _, e := range exts {
|
||||
if e.Tier == tier {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
exts = filtered
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"data": exts})
|
||||
}
|
||||
|
||||
// UpdateUserExtensionSettings saves per-user settings for an extension.
|
||||
// POST /api/v1/extensions/:id/settings
|
||||
func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
extID := c.Param("id")
|
||||
|
||||
// Verify extension exists
|
||||
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), extID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(404, gin.H{"error": "extension not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to fetch extension"})
|
||||
return
|
||||
}
|
||||
|
||||
// System extensions can't be disabled by users
|
||||
var body struct {
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
IsEnabled *bool `json:"is_enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(400, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if body.IsEnabled != nil {
|
||||
if ext.IsSystem && !*body.IsEnabled {
|
||||
c.JSON(403, gin.H{"error": "system extensions cannot be disabled"})
|
||||
return
|
||||
}
|
||||
enabled = *body.IsEnabled
|
||||
}
|
||||
|
||||
settings := json.RawMessage("{}")
|
||||
if body.Settings != nil {
|
||||
settings = body.Settings
|
||||
}
|
||||
|
||||
eus := &models.ExtensionUserSettings{
|
||||
ExtensionID: extID,
|
||||
UserID: userID,
|
||||
Settings: settings,
|
||||
IsEnabled: enabled,
|
||||
}
|
||||
if err := h.stores.Extensions.SetUserSettings(c.Request.Context(), eus); err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to save settings"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ── Admin endpoints ─────────────────────────────
|
||||
|
||||
// AdminListExtensions returns all extensions (enabled and disabled).
|
||||
// GET /api/v1/admin/extensions
|
||||
func (h *ExtensionHandler) AdminListExtensions(c *gin.Context) {
|
||||
exts, err := h.stores.Extensions.ListAll(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to list extensions"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"data": exts})
|
||||
}
|
||||
|
||||
// AdminInstallExtension installs an extension from a manifest.
|
||||
// POST /api/v1/admin/extensions
|
||||
func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
var body struct {
|
||||
ExtID string `json:"ext_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Version string `json:"version"`
|
||||
Tier string `json:"tier"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Manifest json.RawMessage `json:"manifest"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(400, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Defaults
|
||||
if body.Version == "" {
|
||||
body.Version = "0.0.0"
|
||||
}
|
||||
if body.Tier == "" {
|
||||
body.Tier = models.ExtTierBrowser
|
||||
}
|
||||
if body.Manifest == nil {
|
||||
body.Manifest = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
// Check for duplicate ext_id
|
||||
existing, err := h.stores.Extensions.GetByExtID(c.Request.Context(), body.ExtID)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
c.JSON(500, gin.H{"error": "failed to check existing extension"})
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
|
||||
return
|
||||
}
|
||||
|
||||
ext := &models.Extension{
|
||||
ExtID: body.ExtID,
|
||||
Name: body.Name,
|
||||
Version: body.Version,
|
||||
Tier: body.Tier,
|
||||
Description: body.Description,
|
||||
Author: body.Author,
|
||||
Manifest: body.Manifest,
|
||||
IsSystem: body.IsSystem,
|
||||
IsEnabled: body.IsEnabled,
|
||||
Scope: models.ScopeGlobal,
|
||||
InstalledBy: &userID,
|
||||
}
|
||||
|
||||
if err := h.stores.Extensions.Create(c.Request.Context(), ext); err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to install extension"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(201, gin.H{"data": ext})
|
||||
}
|
||||
|
||||
// AdminUpdateExtension updates an extension's config.
|
||||
// PUT /api/v1/admin/extensions/:id
|
||||
func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(404, gin.H{"error": "extension not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to fetch extension"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
IsSystem *bool `json:"is_system"`
|
||||
IsEnabled *bool `json:"is_enabled"`
|
||||
Manifest *json.RawMessage `json:"manifest"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(400, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if body.Name != nil {
|
||||
ext.Name = *body.Name
|
||||
}
|
||||
if body.Description != nil {
|
||||
ext.Description = *body.Description
|
||||
}
|
||||
if body.IsSystem != nil {
|
||||
ext.IsSystem = *body.IsSystem
|
||||
}
|
||||
if body.IsEnabled != nil {
|
||||
ext.IsEnabled = *body.IsEnabled
|
||||
}
|
||||
if body.Manifest != nil {
|
||||
ext.Manifest = *body.Manifest
|
||||
}
|
||||
|
||||
if err := h.stores.Extensions.Update(c.Request.Context(), id, ext); err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to update extension"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"data": ext})
|
||||
}
|
||||
|
||||
// ServeExtensionAsset serves browser extension JS files.
|
||||
// GET /api/v1/extensions/:id/assets/*path
|
||||
//
|
||||
// For the MVP, scripts are stored inline in manifest._script.
|
||||
// The :id param is the ext_id (e.g. "mermaid-renderer"), not the UUID.
|
||||
func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
|
||||
extID := c.Param("id")
|
||||
// path := c.Param("path") // reserved for future multi-file support
|
||||
|
||||
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{"error": "extension not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !ext.IsEnabled {
|
||||
c.JSON(404, gin.H{"error": "extension not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract inline script from manifest
|
||||
var manifest map[string]json.RawMessage
|
||||
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
|
||||
c.JSON(500, gin.H{"error": "invalid manifest"})
|
||||
return
|
||||
}
|
||||
|
||||
scriptRaw, ok := manifest["_script"]
|
||||
if !ok {
|
||||
c.JSON(404, gin.H{"error": "no script in manifest"})
|
||||
return
|
||||
}
|
||||
|
||||
// _script is a JSON string — unquote it
|
||||
var script string
|
||||
if err := json.Unmarshal(scriptRaw, &script); err != nil {
|
||||
c.JSON(500, gin.H{"error": "invalid script in manifest"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/javascript; charset=utf-8")
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.String(200, script)
|
||||
}
|
||||
|
||||
// GetExtensionManifest returns the manifest for a specific extension.
|
||||
// GET /api/v1/extensions/:id/manifest
|
||||
func (h *ExtensionHandler) GetExtensionManifest(c *gin.Context) {
|
||||
extID := c.Param("id")
|
||||
|
||||
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{"error": "extension not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"data": ext.Manifest})
|
||||
}
|
||||
|
||||
// ListBrowserToolSchemas returns tool schemas from all enabled browser extensions.
|
||||
// Used by the completion handler to include browser tools in LLM requests.
|
||||
// GET /api/v1/extensions/tools
|
||||
func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to list extensions"})
|
||||
return
|
||||
}
|
||||
|
||||
var toolSchemas []json.RawMessage
|
||||
for _, ext := range exts {
|
||||
if ext.Tier != "browser" {
|
||||
continue
|
||||
}
|
||||
var manifest struct {
|
||||
Tools []json.RawMessage `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
|
||||
continue
|
||||
}
|
||||
toolSchemas = append(toolSchemas, manifest.Tools...)
|
||||
}
|
||||
|
||||
if toolSchemas == nil {
|
||||
toolSchemas = make([]json.RawMessage, 0)
|
||||
}
|
||||
c.JSON(200, gin.H{"data": toolSchemas})
|
||||
}
|
||||
|
||||
// AdminUninstallExtension removes an extension.
|
||||
// DELETE /api/v1/admin/extensions/:id
|
||||
func (h *ExtensionHandler) AdminUninstallExtension(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
_, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(404, gin.H{"error": "extension not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to fetch extension"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Extensions.Delete(c.Request.Context(), id); err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to uninstall extension"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.POST("/channels", channels.CreateChannel)
|
||||
|
||||
// Completions
|
||||
completions := NewCompletionHandler(nil, stores)
|
||||
completions := NewCompletionHandler(nil, stores, nil)
|
||||
protected.POST("/chat/completions", completions.Complete)
|
||||
|
||||
// Admin routes
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
@@ -59,11 +60,12 @@ type cursorRequest struct {
|
||||
type MessageHandler struct {
|
||||
vault *crypto.KeyResolver
|
||||
stores store.Stores
|
||||
hub *events.Hub
|
||||
}
|
||||
|
||||
// NewMessageHandler creates a new message handler.
|
||||
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores) *MessageHandler {
|
||||
return &MessageHandler{vault: vault, stores: stores}
|
||||
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *MessageHandler {
|
||||
return &MessageHandler{vault: vault, stores: stores, hub: hub}
|
||||
}
|
||||
|
||||
// ── List Messages (flat, all branches) ──────
|
||||
@@ -358,7 +360,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
// ── Resolve model + provider ──
|
||||
|
||||
comp := NewCompletionHandler(h.vault, h.stores)
|
||||
comp := NewCompletionHandler(h.vault, h.stores, h.hub)
|
||||
|
||||
var presetSystemPrompt string
|
||||
model := req.Model
|
||||
@@ -449,13 +451,14 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Attach tool definitions (same as normal completion)
|
||||
if caps.ToolCalling && tools.HasTools() {
|
||||
provReq.Tools = comp.buildToolDefs()
|
||||
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
||||
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
||||
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools)
|
||||
}
|
||||
|
||||
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
|
||||
|
||||
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID)
|
||||
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, h.hub)
|
||||
|
||||
// Persist as sibling (regen) with tool activity
|
||||
if result.Content != "" {
|
||||
|
||||
178
server/handlers/seed_extensions.go
Normal file
178
server/handlers/seed_extensions.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// builtinManifest is the on-disk manifest.json shape.
|
||||
type builtinManifest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Tier string `json:"tier"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Permissions json.RawMessage `json:"permissions"`
|
||||
Tools json.RawMessage `json:"tools"`
|
||||
Surfaces json.RawMessage `json:"surfaces"`
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
}
|
||||
|
||||
// SeedBuiltinExtensions scans a directory for builtin extension bundles
|
||||
// (each subdirectory contains manifest.json + script.js) and upserts them
|
||||
// into the extensions table as system extensions.
|
||||
//
|
||||
// Layout:
|
||||
//
|
||||
// extensions/builtin/
|
||||
// mermaid-renderer/
|
||||
// manifest.json
|
||||
// script.js
|
||||
//
|
||||
// Idempotent: skips extensions whose version matches what's already installed,
|
||||
// updates those with a newer version, creates new ones.
|
||||
func SeedBuiltinExtensions(stores store.Stores, extensionsDir string) {
|
||||
if stores.Extensions == nil {
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(extensionsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf(" 📦 No builtin extensions directory at %s (skipping)", extensionsDir)
|
||||
return
|
||||
}
|
||||
log.Printf("⚠ Failed to read extensions directory %s: %v", extensionsDir, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
seeded, updated, skipped := 0, 0, 0
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
extDir := filepath.Join(extensionsDir, entry.Name())
|
||||
manifestPath := filepath.Join(extDir, "manifest.json")
|
||||
scriptPath := filepath.Join(extDir, "script.js")
|
||||
|
||||
// Read manifest
|
||||
manifestData, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Skipping %s: no manifest.json: %v", entry.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
var manifest builtinManifest
|
||||
if err := json.Unmarshal(manifestData, &manifest); err != nil {
|
||||
log.Printf("⚠ Skipping %s: invalid manifest.json: %v", entry.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if manifest.ID == "" {
|
||||
log.Printf("⚠ Skipping %s: manifest missing 'id'", entry.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
// Read script (optional — some extensions might be manifest-only)
|
||||
var script string
|
||||
if scriptData, err := os.ReadFile(scriptPath); err == nil {
|
||||
script = string(scriptData)
|
||||
}
|
||||
|
||||
// Build the full manifest JSONB with _script inlined
|
||||
fullManifest := buildFullManifest(manifestData, script)
|
||||
|
||||
// Check if already installed
|
||||
existing, err := stores.Extensions.GetByExtID(ctx, manifest.ID)
|
||||
if err == nil {
|
||||
// Already exists — check version
|
||||
if existing.Version == manifest.Version {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
// Version changed — update
|
||||
existing.Name = manifest.Name
|
||||
existing.Version = manifest.Version
|
||||
existing.Description = manifest.Description
|
||||
existing.Author = manifest.Author
|
||||
existing.Manifest = fullManifest
|
||||
existing.IsSystem = true
|
||||
existing.IsEnabled = true
|
||||
|
||||
if err := stores.Extensions.Update(ctx, existing.ID, existing); err != nil {
|
||||
log.Printf("⚠ Failed to update builtin extension %s: %v", manifest.ID, err)
|
||||
continue
|
||||
}
|
||||
updated++
|
||||
continue
|
||||
}
|
||||
|
||||
// New extension — create
|
||||
tier := manifest.Tier
|
||||
if tier == "" {
|
||||
tier = models.ExtTierBrowser
|
||||
}
|
||||
|
||||
ext := &models.Extension{
|
||||
ExtID: manifest.ID,
|
||||
Name: manifest.Name,
|
||||
Version: manifest.Version,
|
||||
Tier: tier,
|
||||
Description: manifest.Description,
|
||||
Author: manifest.Author,
|
||||
Manifest: fullManifest,
|
||||
IsSystem: true,
|
||||
IsEnabled: true,
|
||||
Scope: "global",
|
||||
}
|
||||
|
||||
if err := stores.Extensions.Create(ctx, ext); err != nil {
|
||||
log.Printf("⚠ Failed to seed builtin extension %s: %v", manifest.ID, err)
|
||||
continue
|
||||
}
|
||||
seeded++
|
||||
}
|
||||
|
||||
if seeded+updated > 0 {
|
||||
log.Printf(" 📦 Builtin extensions: %d seeded, %d updated, %d unchanged", seeded, updated, skipped)
|
||||
} else if skipped > 0 {
|
||||
log.Printf(" 📦 Builtin extensions: %d up to date", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// buildFullManifest merges the raw manifest JSON with an inline _script field.
|
||||
func buildFullManifest(rawManifest []byte, script string) json.RawMessage {
|
||||
if script == "" {
|
||||
return json.RawMessage(rawManifest)
|
||||
}
|
||||
|
||||
// Parse manifest as generic map, add _script, re-serialize
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawManifest, &m); err != nil {
|
||||
// Fallback: just return raw manifest
|
||||
return json.RawMessage(rawManifest)
|
||||
}
|
||||
|
||||
scriptJSON, err := json.Marshal(script)
|
||||
if err != nil {
|
||||
return json.RawMessage(rawManifest)
|
||||
}
|
||||
|
||||
m["_script"] = json.RawMessage(scriptJSON)
|
||||
result, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return json.RawMessage(rawManifest)
|
||||
}
|
||||
|
||||
return json.RawMessage(result)
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
)
|
||||
@@ -40,6 +44,7 @@ func streamWithToolLoop(
|
||||
cfg providers.ProviderConfig,
|
||||
req *providers.CompletionRequest,
|
||||
model, userID, channelID string,
|
||||
hub *events.Hub,
|
||||
) streamResult {
|
||||
// Set SSE headers
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
@@ -171,8 +176,25 @@ func streamWithToolLoop(
|
||||
Arguments: tc.Function.Arguments,
|
||||
}
|
||||
|
||||
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
|
||||
toolResult := tools.ExecuteCall(c.Request.Context(), execCtx, call)
|
||||
var toolResult tools.ToolResult
|
||||
|
||||
// Check if tool is registered locally (server-side)
|
||||
if tools.Get(call.Name) != nil {
|
||||
log.Printf("🔧 Executing tool (server): %s (call %s)", call.Name, call.ID)
|
||||
toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call)
|
||||
} else if hub != nil && hub.IsConnected(userID) {
|
||||
// Route to browser via WebSocket bridge
|
||||
log.Printf("🔧 Executing tool (browser): %s (call %s)", call.Name, call.ID)
|
||||
toolResult = executeBrowserTool(hub, userID, call)
|
||||
} else {
|
||||
log.Printf("⚠️ Unknown tool: %s (call %s)", call.Name, call.ID)
|
||||
toolResult = tools.ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Content: `{"error":"unknown tool or browser not connected"}`,
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Notify client about tool result
|
||||
resultJSON, _ := json.Marshal(map[string]interface{}{
|
||||
@@ -214,3 +236,68 @@ func streamWithToolLoop(
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const browserToolTimeout = 30 * time.Second
|
||||
|
||||
// executeBrowserTool sends a tool call to the user's browser via WebSocket
|
||||
// and blocks until a result is returned or the timeout elapses.
|
||||
func executeBrowserTool(hub *events.Hub, userID string, call tools.ToolCall) tools.ToolResult {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
callID := hex.EncodeToString(b)
|
||||
|
||||
// Publish tool.call event to the user's browser
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"call_id": callID,
|
||||
"tool": call.Name,
|
||||
"arguments": json.RawMessage(call.Arguments),
|
||||
})
|
||||
|
||||
hub.SendToUser(userID, events.Event{
|
||||
Label: "tool.call." + callID,
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
})
|
||||
|
||||
// Block until browser sends tool.result.{callID} or timeout
|
||||
event, ok := hub.GetBus().WaitFor("tool.result."+callID, browserToolTimeout)
|
||||
if !ok {
|
||||
log.Printf("⚠️ Browser tool %s timed out after %v (call %s)", call.Name, browserToolTimeout, callID)
|
||||
return tools.ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Content: `{"error":"browser tool timed out (30s) — browser may be disconnected"}`,
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse result from the browser event payload
|
||||
var result struct {
|
||||
CallID string `json:"call_id"`
|
||||
Result string `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(event.Payload, &result); err != nil {
|
||||
return tools.ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Content: `{"error":"invalid result from browser tool"}`,
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return tools.ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Content: result.Error,
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
return tools.ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Content: result.Result,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ func main() {
|
||||
|
||||
// Seed additional users from env (dev/test only, skipped in production)
|
||||
handlers.SeedUsers(cfg, stores)
|
||||
|
||||
// Seed builtin extensions from disk (idempotent, version-aware)
|
||||
handlers.SeedBuiltinExtensions(stores, "extensions/builtin")
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
@@ -126,6 +129,12 @@ func main() {
|
||||
authGroup.POST("/logout", auth.Logout)
|
||||
}
|
||||
|
||||
// ── Public extension assets ────────────────
|
||||
// Script tags can't send Authorization headers, so asset serving must be public.
|
||||
// Extension JS is the same for all users — no user-specific data.
|
||||
extH := handlers.NewExtensionHandler(stores)
|
||||
api.GET("/extensions/:id/assets/*path", extH.ServeExtensionAsset)
|
||||
|
||||
// ── Protected routes ────────────────────
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
@@ -139,7 +148,7 @@ func main() {
|
||||
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
||||
|
||||
// Messages
|
||||
msgs := handlers.NewMessageHandler(keyResolver, stores)
|
||||
msgs := handlers.NewMessageHandler(keyResolver, stores, hub)
|
||||
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
||||
|
||||
@@ -151,7 +160,7 @@ func main() {
|
||||
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
|
||||
|
||||
// Chat Completions
|
||||
comp := handlers.NewCompletionHandler(keyResolver, stores)
|
||||
comp := handlers.NewCompletionHandler(keyResolver, stores, hub)
|
||||
protected.POST("/chat/completions", comp.Complete)
|
||||
|
||||
// Summarize & Continue
|
||||
@@ -258,6 +267,12 @@ func main() {
|
||||
// Public global settings (non-admin users can read safe subset)
|
||||
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
|
||||
protected.GET("/settings/public", adm.PublicSettings)
|
||||
|
||||
// Extensions (user-facing)
|
||||
protected.GET("/extensions", extH.ListUserExtensions)
|
||||
protected.POST("/extensions/:id/settings", extH.UpdateUserExtensionSettings)
|
||||
protected.GET("/extensions/:id/manifest", extH.GetExtensionManifest)
|
||||
protected.GET("/extensions/tools", extH.ListBrowserToolSchemas)
|
||||
}
|
||||
|
||||
// ── Admin routes ────────────────────────
|
||||
@@ -336,6 +351,13 @@ func main() {
|
||||
admin.GET("/pricing", usageH.ListPricing)
|
||||
admin.PUT("/pricing", usageH.UpsertPricing)
|
||||
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
|
||||
|
||||
// Extensions (admin)
|
||||
extAdm := handlers.NewExtensionHandler(stores)
|
||||
admin.GET("/extensions", extAdm.AdminListExtensions)
|
||||
admin.POST("/extensions", extAdm.AdminInstallExtension)
|
||||
admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension)
|
||||
admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -536,6 +536,49 @@ func (m *JSONMap) Scan(src interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Extensions ──────────────────────────────
|
||||
|
||||
// Extension tier constants
|
||||
const (
|
||||
ExtTierBrowser = "browser"
|
||||
ExtTierStarlark = "starlark"
|
||||
ExtTierSidecar = "sidecar"
|
||||
)
|
||||
|
||||
// Extension represents an installed extension in the registry.
|
||||
type Extension struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
ExtID string `json:"ext_id" db:"ext_id"` // manifest id
|
||||
Name string `json:"name" db:"name"`
|
||||
Version string `json:"version" db:"version"`
|
||||
Tier string `json:"tier" db:"tier"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Author string `json:"author" db:"author"`
|
||||
Manifest json.RawMessage `json:"manifest" db:"manifest"`
|
||||
IsSystem bool `json:"is_system" db:"is_system"`
|
||||
IsEnabled bool `json:"is_enabled" db:"is_enabled"`
|
||||
Scope string `json:"scope" db:"scope"`
|
||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||
InstalledBy *string `json:"installed_by,omitempty" db:"installed_by"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// ExtensionUserSettings stores per-user overrides for an extension.
|
||||
type ExtensionUserSettings struct {
|
||||
ExtensionID string `json:"extension_id" db:"extension_id"`
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Settings json.RawMessage `json:"settings" db:"settings"`
|
||||
IsEnabled bool `json:"is_enabled" db:"is_enabled"`
|
||||
}
|
||||
|
||||
// UserExtension combines extension info with user-specific settings for API responses.
|
||||
type UserExtension struct {
|
||||
Extension
|
||||
UserEnabled *bool `json:"user_enabled,omitempty"`
|
||||
UserSettings *json.RawMessage `json:"user_settings,omitempty"`
|
||||
}
|
||||
|
||||
func NullString(s *string) sql.NullString {
|
||||
if s == nil {
|
||||
return sql.NullString{}
|
||||
|
||||
@@ -32,6 +32,7 @@ type Stores struct {
|
||||
GlobalConfig GlobalConfigStore
|
||||
Usage UsageStore
|
||||
Pricing PricingStore
|
||||
Extensions ExtensionStore
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -316,6 +317,29 @@ type PricingStore interface {
|
||||
Delete(ctx context.Context, providerConfigID, modelID string) error
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// EXTENSION STORE
|
||||
// =========================================
|
||||
|
||||
type ExtensionStore interface {
|
||||
// Admin CRUD
|
||||
Create(ctx context.Context, ext *models.Extension) error
|
||||
GetByID(ctx context.Context, id string) (*models.Extension, error)
|
||||
GetByExtID(ctx context.Context, extID string) (*models.Extension, error)
|
||||
Update(ctx context.Context, id string, ext *models.Extension) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// Listing
|
||||
ListAll(ctx context.Context) ([]models.Extension, error)
|
||||
ListEnabled(ctx context.Context) ([]models.Extension, error)
|
||||
ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error)
|
||||
|
||||
// User settings
|
||||
GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error)
|
||||
SetUserSettings(ctx context.Context, s *models.ExtensionUserSettings) error
|
||||
DeleteUserSettings(ctx context.Context, extID, userID string) error
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// SHARED TYPES
|
||||
// =========================================
|
||||
|
||||
194
server/store/postgres/extension.go
Normal file
194
server/store/postgres/extension.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
type ExtensionStore struct{}
|
||||
|
||||
func NewExtensionStore() *ExtensionStore { return &ExtensionStore{} }
|
||||
|
||||
func (s *ExtensionStore) Create(ctx context.Context, ext *models.Extension) error {
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO extensions (ext_id, name, version, tier, description, author,
|
||||
manifest, is_system, is_enabled, scope, team_id, installed_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
ext.ExtID, ext.Name, ext.Version, ext.Tier, ext.Description, ext.Author,
|
||||
ext.Manifest, ext.IsSystem, ext.IsEnabled, ext.Scope,
|
||||
models.NullString(ext.TeamID), models.NullString(ext.InstalledBy),
|
||||
).Scan(&ext.ID, &ext.CreatedAt, &ext.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetByID(ctx context.Context, id string) (*models.Extension, error) {
|
||||
return s.scanOne(ctx, `SELECT * FROM extensions WHERE id = $1`, id)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetByExtID(ctx context.Context, extID string) (*models.Extension, error) {
|
||||
return s.scanOne(ctx, `SELECT * FROM extensions WHERE ext_id = $1`, extID)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) Update(ctx context.Context, id string, ext *models.Extension) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE extensions SET
|
||||
name = $2, version = $3, description = $4, author = $5,
|
||||
manifest = $6, is_system = $7, is_enabled = $8,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
id, ext.Name, ext.Version, ext.Description, ext.Author,
|
||||
ext.Manifest, ext.IsSystem, ext.IsEnabled,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `DELETE FROM extensions WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error) {
|
||||
return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) {
|
||||
return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = true ORDER BY name`)
|
||||
}
|
||||
|
||||
// ListForUser returns all enabled extensions with user-specific overrides merged in.
|
||||
// System extensions are always included (users can't disable them).
|
||||
// Non-system extensions respect per-user enabled toggle.
|
||||
func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT e.id, e.ext_id, e.name, e.version, e.tier, e.description, e.author,
|
||||
e.manifest, e.is_system, e.is_enabled, e.scope, e.team_id, e.installed_by,
|
||||
e.created_at, e.updated_at,
|
||||
eus.is_enabled AS user_enabled,
|
||||
eus.settings AS user_settings
|
||||
FROM extensions e
|
||||
LEFT JOIN extension_user_settings eus
|
||||
ON eus.extension_id = e.id AND eus.user_id = $1
|
||||
WHERE e.is_enabled = true
|
||||
AND (e.is_system = true OR COALESCE(eus.is_enabled, true) = true)
|
||||
ORDER BY e.name`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.UserExtension
|
||||
for rows.Next() {
|
||||
var ue models.UserExtension
|
||||
var teamID, installedBy sql.NullString
|
||||
var userEnabled sql.NullBool
|
||||
var userSettings []byte
|
||||
|
||||
if err := rows.Scan(
|
||||
&ue.ID, &ue.ExtID, &ue.Name, &ue.Version, &ue.Tier,
|
||||
&ue.Description, &ue.Author, &ue.Manifest, &ue.IsSystem,
|
||||
&ue.IsEnabled, &ue.Scope, &teamID, &installedBy,
|
||||
&ue.CreatedAt, &ue.UpdatedAt,
|
||||
&userEnabled, &userSettings,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ue.TeamID = NullableStringPtr(teamID)
|
||||
ue.InstalledBy = NullableStringPtr(installedBy)
|
||||
if userEnabled.Valid {
|
||||
ue.UserEnabled = &userEnabled.Bool
|
||||
}
|
||||
if userSettings != nil {
|
||||
raw := json.RawMessage(userSettings)
|
||||
ue.UserSettings = &raw
|
||||
}
|
||||
result = append(result, ue)
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]models.UserExtension, 0)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) {
|
||||
var eus models.ExtensionUserSettings
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT extension_id, user_id, settings, is_enabled
|
||||
FROM extension_user_settings
|
||||
WHERE extension_id = $1 AND user_id = $2`,
|
||||
extID, userID,
|
||||
).Scan(&eus.ExtensionID, &eus.UserID, &eus.Settings, &eus.IsEnabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &eus, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.ExtensionUserSettings) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO extension_user_settings (extension_id, user_id, settings, is_enabled)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (extension_id, user_id)
|
||||
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
|
||||
eus.ExtensionID, eus.UserID, eus.Settings, eus.IsEnabled,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
DELETE FROM extension_user_settings WHERE extension_id = $1 AND user_id = $2`,
|
||||
extID, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────
|
||||
|
||||
func (s *ExtensionStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.Extension, error) {
|
||||
var ext models.Extension
|
||||
var teamID, installedBy sql.NullString
|
||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
|
||||
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
|
||||
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
|
||||
&ext.CreatedAt, &ext.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ext.TeamID = NullableStringPtr(teamID)
|
||||
ext.InstalledBy = NullableStringPtr(installedBy)
|
||||
return &ext, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]models.Extension, error) {
|
||||
rows, err := DB.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.Extension
|
||||
for rows.Next() {
|
||||
var ext models.Extension
|
||||
var teamID, installedBy sql.NullString
|
||||
if err := rows.Scan(
|
||||
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
|
||||
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
|
||||
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
|
||||
&ext.CreatedAt, &ext.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ext.TeamID = NullableStringPtr(teamID)
|
||||
ext.InstalledBy = NullableStringPtr(installedBy)
|
||||
result = append(result, ext)
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]models.Extension, 0)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -25,5 +25,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
GlobalConfig: NewGlobalConfigStore(),
|
||||
Usage: NewUsageStore(),
|
||||
Pricing: NewPricingStore(),
|
||||
Extensions: NewExtensionStore(),
|
||||
}
|
||||
}
|
||||
|
||||
384
server/tools/calculator.go
Normal file
384
server/tools/calculator.go
Normal file
@@ -0,0 +1,384 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(&CalculatorTool{})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// calculator
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type CalculatorTool struct{}
|
||||
|
||||
func (t *CalculatorTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "calculator",
|
||||
Description: "Evaluate mathematical expressions with precision. Supports +, -, *, /, ^ (power), % (modulo), parentheses, and functions: sqrt, abs, ceil, floor, round, log, log2, log10, sin, cos, tan, pi, e. Use this for any arithmetic, unit conversions, percentages, or calculations instead of doing mental math.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"expression": Prop("string", "Mathematical expression to evaluate, e.g. '(100 * 1.08) ^ 5' or 'sqrt(144) + log10(1000)'"),
|
||||
}, []string{"expression"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *CalculatorTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.Expression == "" {
|
||||
return "", fmt.Errorf("expression is required")
|
||||
}
|
||||
|
||||
// Normalize: replace ^ with ** for power, then rewrite for Go parser
|
||||
expr := normalizeExpr(args.Expression)
|
||||
|
||||
result, err := evalExpr(expr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("evaluation error: %w", err)
|
||||
}
|
||||
|
||||
// Format result nicely
|
||||
var display string
|
||||
if result == math.Trunc(result) && !math.IsInf(result, 0) && !math.IsNaN(result) {
|
||||
display = strconv.FormatFloat(result, 'f', 0, 64)
|
||||
} else {
|
||||
display = strconv.FormatFloat(result, 'g', 15, 64)
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"expression": args.Expression,
|
||||
"result": result,
|
||||
"display": display,
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(resp)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// ── Expression Normalization ────────────────
|
||||
|
||||
func normalizeExpr(s string) string {
|
||||
// Replace common aliases
|
||||
s = strings.ReplaceAll(s, "×", "*")
|
||||
s = strings.ReplaceAll(s, "÷", "/")
|
||||
s = strings.ReplaceAll(s, "**", "^")
|
||||
return s
|
||||
}
|
||||
|
||||
// ── Recursive Descent Evaluator ─────────────
|
||||
// Supports: +, -, *, /, ^, %, unary -, parentheses, function calls, constants
|
||||
|
||||
type calcParser struct {
|
||||
input string
|
||||
pos int
|
||||
}
|
||||
|
||||
func evalExpr(input string) (float64, error) {
|
||||
p := &calcParser{input: strings.TrimSpace(input)}
|
||||
result, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.pos < len(p.input) {
|
||||
return 0, fmt.Errorf("unexpected character at position %d: %q", p.pos, string(p.input[p.pos]))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *calcParser) parseExpr() (float64, error) {
|
||||
return p.parseAddSub()
|
||||
}
|
||||
|
||||
func (p *calcParser) parseAddSub() (float64, error) {
|
||||
left, err := p.parseMulDiv()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for {
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.input) {
|
||||
break
|
||||
}
|
||||
op := p.input[p.pos]
|
||||
if op != '+' && op != '-' {
|
||||
break
|
||||
}
|
||||
p.pos++
|
||||
right, err := p.parseMulDiv()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if op == '+' {
|
||||
left += right
|
||||
} else {
|
||||
left -= right
|
||||
}
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func (p *calcParser) parseMulDiv() (float64, error) {
|
||||
left, err := p.parsePower()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for {
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.input) {
|
||||
break
|
||||
}
|
||||
op := p.input[p.pos]
|
||||
if op != '*' && op != '/' && op != '%' {
|
||||
break
|
||||
}
|
||||
p.pos++
|
||||
right, err := p.parsePower()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
switch op {
|
||||
case '*':
|
||||
left *= right
|
||||
case '/':
|
||||
if right == 0 {
|
||||
return 0, fmt.Errorf("division by zero")
|
||||
}
|
||||
left /= right
|
||||
case '%':
|
||||
if right == 0 {
|
||||
return 0, fmt.Errorf("modulo by zero")
|
||||
}
|
||||
left = math.Mod(left, right)
|
||||
}
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func (p *calcParser) parsePower() (float64, error) {
|
||||
base, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.pos < len(p.input) && p.input[p.pos] == '^' {
|
||||
p.pos++
|
||||
// Right-associative: 2^3^2 = 2^(3^2)
|
||||
exp, err := p.parsePower()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return math.Pow(base, exp), nil
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func (p *calcParser) parseUnary() (float64, error) {
|
||||
p.skipSpaces()
|
||||
if p.pos < len(p.input) && p.input[p.pos] == '-' {
|
||||
p.pos++
|
||||
val, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return -val, nil
|
||||
}
|
||||
if p.pos < len(p.input) && p.input[p.pos] == '+' {
|
||||
p.pos++
|
||||
return p.parseUnary()
|
||||
}
|
||||
return p.parsePrimary()
|
||||
}
|
||||
|
||||
func (p *calcParser) parsePrimary() (float64, error) {
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.input) {
|
||||
return 0, fmt.Errorf("unexpected end of expression")
|
||||
}
|
||||
|
||||
// Parenthesized expression
|
||||
if p.input[p.pos] == '(' {
|
||||
p.pos++
|
||||
val, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.input) || p.input[p.pos] != ')' {
|
||||
return 0, fmt.Errorf("missing closing parenthesis")
|
||||
}
|
||||
p.pos++
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// Identifier (function or constant)
|
||||
if isAlpha(p.input[p.pos]) {
|
||||
name := p.parseIdent()
|
||||
return p.evalIdentifier(name)
|
||||
}
|
||||
|
||||
// Number
|
||||
return p.parseNumber()
|
||||
}
|
||||
|
||||
func (p *calcParser) parseIdent() string {
|
||||
start := p.pos
|
||||
for p.pos < len(p.input) && (isAlpha(p.input[p.pos]) || isDigit(p.input[p.pos])) {
|
||||
p.pos++
|
||||
}
|
||||
return strings.ToLower(p.input[start:p.pos])
|
||||
}
|
||||
|
||||
func (p *calcParser) evalIdentifier(name string) (float64, error) {
|
||||
// Constants
|
||||
switch name {
|
||||
case "pi":
|
||||
return math.Pi, nil
|
||||
case "e":
|
||||
return math.E, nil
|
||||
case "inf":
|
||||
return math.Inf(1), nil
|
||||
}
|
||||
|
||||
// Functions (require parenthesized argument)
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.input) || p.input[p.pos] != '(' {
|
||||
return 0, fmt.Errorf("unknown constant %q (did you mean %s(x)?)", name, name)
|
||||
}
|
||||
p.pos++ // consume '('
|
||||
|
||||
arg, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Check for second argument (e.g. pow(2, 3))
|
||||
p.skipSpaces()
|
||||
var arg2 float64
|
||||
hasArg2 := false
|
||||
if p.pos < len(p.input) && p.input[p.pos] == ',' {
|
||||
p.pos++
|
||||
arg2, err = p.parseExpr()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
hasArg2 = true
|
||||
}
|
||||
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.input) || p.input[p.pos] != ')' {
|
||||
return 0, fmt.Errorf("missing closing parenthesis for %s()", name)
|
||||
}
|
||||
p.pos++
|
||||
|
||||
switch name {
|
||||
case "sqrt":
|
||||
return math.Sqrt(arg), nil
|
||||
case "abs":
|
||||
return math.Abs(arg), nil
|
||||
case "ceil":
|
||||
return math.Ceil(arg), nil
|
||||
case "floor":
|
||||
return math.Floor(arg), nil
|
||||
case "round":
|
||||
return math.Round(arg), nil
|
||||
case "log", "ln":
|
||||
return math.Log(arg), nil
|
||||
case "log2":
|
||||
return math.Log2(arg), nil
|
||||
case "log10":
|
||||
return math.Log10(arg), nil
|
||||
case "sin":
|
||||
return math.Sin(arg), nil
|
||||
case "cos":
|
||||
return math.Cos(arg), nil
|
||||
case "tan":
|
||||
return math.Tan(arg), nil
|
||||
case "asin":
|
||||
return math.Asin(arg), nil
|
||||
case "acos":
|
||||
return math.Acos(arg), nil
|
||||
case "atan":
|
||||
return math.Atan(arg), nil
|
||||
case "exp":
|
||||
return math.Exp(arg), nil
|
||||
case "pow":
|
||||
if !hasArg2 {
|
||||
return 0, fmt.Errorf("pow() requires two arguments: pow(base, exponent)")
|
||||
}
|
||||
return math.Pow(arg, arg2), nil
|
||||
case "max":
|
||||
if !hasArg2 {
|
||||
return 0, fmt.Errorf("max() requires two arguments")
|
||||
}
|
||||
return math.Max(arg, arg2), nil
|
||||
case "min":
|
||||
if !hasArg2 {
|
||||
return 0, fmt.Errorf("min() requires two arguments")
|
||||
}
|
||||
return math.Min(arg, arg2), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown function %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *calcParser) parseNumber() (float64, error) {
|
||||
start := p.pos
|
||||
if p.pos < len(p.input) && p.input[p.pos] == '.' {
|
||||
p.pos++
|
||||
}
|
||||
if p.pos >= len(p.input) || !isDigit(p.input[p.pos]) {
|
||||
if p.pos > start {
|
||||
// lone dot
|
||||
return 0, fmt.Errorf("invalid number at position %d", start)
|
||||
}
|
||||
return 0, fmt.Errorf("expected number at position %d, got %q", p.pos, string(p.input[p.pos]))
|
||||
}
|
||||
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
|
||||
p.pos++
|
||||
}
|
||||
if p.pos < len(p.input) && p.input[p.pos] == '.' {
|
||||
p.pos++
|
||||
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
// Scientific notation
|
||||
if p.pos < len(p.input) && (p.input[p.pos] == 'e' || p.input[p.pos] == 'E') {
|
||||
p.pos++
|
||||
if p.pos < len(p.input) && (p.input[p.pos] == '+' || p.input[p.pos] == '-') {
|
||||
p.pos++
|
||||
}
|
||||
for p.pos < len(p.input) && isDigit(p.input[p.pos]) {
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
|
||||
val, err := strconv.ParseFloat(p.input[start:p.pos], 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid number %q", p.input[start:p.pos])
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (p *calcParser) skipSpaces() {
|
||||
for p.pos < len(p.input) && p.input[p.pos] == ' ' {
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
|
||||
func isAlpha(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' }
|
||||
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
|
||||
|
||||
64
server/tools/datetime.go
Normal file
64
server/tools/datetime.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(&DateTimeTool{})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// datetime
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type DateTimeTool struct{}
|
||||
|
||||
func (t *DateTimeTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "datetime",
|
||||
Description: "Get the current date, time, timezone, and day of week. Use this whenever you need to know the current date or time, calculate days between dates, or answer questions about what day it is. Do NOT guess dates — always call this tool.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"timezone": Prop("string", "IANA timezone name, e.g. 'America/New_York', 'UTC', 'Europe/London'. Defaults to UTC."),
|
||||
}, nil),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DateTimeTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
if argsJSON != "" {
|
||||
_ = json.Unmarshal([]byte(argsJSON), &args)
|
||||
}
|
||||
|
||||
loc := time.UTC
|
||||
if args.Timezone != "" {
|
||||
parsed, err := time.LoadLocation(args.Timezone)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid timezone %q: %w", args.Timezone, err)
|
||||
}
|
||||
loc = parsed
|
||||
}
|
||||
|
||||
now := time.Now().In(loc)
|
||||
|
||||
year, week := now.ISOWeek()
|
||||
|
||||
result := map[string]interface{}{
|
||||
"datetime": now.Format(time.RFC3339),
|
||||
"date": now.Format("2006-01-02"),
|
||||
"time": now.Format("15:04:05"),
|
||||
"day": now.Weekday().String(),
|
||||
"timezone": loc.String(),
|
||||
"unix": now.Unix(),
|
||||
"iso_week": fmt.Sprintf("%d-W%02d", year, week),
|
||||
"day_of_year": now.YearDay(),
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(result)
|
||||
return string(b), nil
|
||||
}
|
||||
Reference in New Issue
Block a user