Changeset 0.13.1 (#65)
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools/search"
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
@@ -260,9 +261,32 @@ func (h *AdminHandler) UpdateGlobalSetting(c *gin.Context) {
|
||||
}
|
||||
|
||||
h.auditLog(c, "settings.update", "global_settings", key, nil)
|
||||
|
||||
// Live-apply hooks for settings that have runtime effects
|
||||
if key == "search_config" {
|
||||
applySearchConfig(jsonVal)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "setting updated"})
|
||||
}
|
||||
|
||||
// applySearchConfig updates the active search provider at runtime.
|
||||
func applySearchConfig(raw models.JSONMap) {
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to marshal search config: %v", err)
|
||||
return
|
||||
}
|
||||
var cfg search.Config
|
||||
if err := json.Unmarshal(b, &cfg); err != nil {
|
||||
log.Printf("⚠️ Failed to parse search config: %v", err)
|
||||
return
|
||||
}
|
||||
if err := search.ApplyConfig(cfg); err != nil {
|
||||
log.Printf("⚠️ Failed to apply search config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
||||
// Banner config, branding, etc. — safe subset for non-admin users
|
||||
banner, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "banner")
|
||||
|
||||
@@ -38,6 +38,7 @@ type completionRequest struct {
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty"`
|
||||
AttachmentIDs []string `json:"attachment_ids,omitempty"` // staged attachment UUIDs to include in request
|
||||
DisabledTools []string `json:"disabled_tools,omitempty"` // tool names to exclude from this request
|
||||
}
|
||||
|
||||
// CompletionHandler proxies LLM requests through the backend.
|
||||
@@ -207,7 +208,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
// Attach tool definitions if model supports tool calling and tools are available
|
||||
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
||||
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
||||
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools)
|
||||
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools)
|
||||
}
|
||||
|
||||
// Determine streaming
|
||||
@@ -225,8 +226,15 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
|
||||
// buildToolDefs converts registered tools to provider-format tool definitions.
|
||||
// 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()
|
||||
// Tools whose names appear in disabledTools are excluded.
|
||||
func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool, disabledTools []string) []providers.ToolDef {
|
||||
// Build disabled set for O(1) lookup
|
||||
disabled := make(map[string]bool, len(disabledTools))
|
||||
for _, name := range disabledTools {
|
||||
disabled[name] = true
|
||||
}
|
||||
|
||||
allTools := tools.AllDefinitionsFiltered(disabled)
|
||||
defs := make([]providers.ToolDef, 0, len(allTools))
|
||||
for _, t := range allTools {
|
||||
defs = append(defs, providers.ToolDef{
|
||||
@@ -262,6 +270,9 @@ func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, in
|
||||
continue
|
||||
}
|
||||
for _, t := range manifest.Tools {
|
||||
if disabled[t.Name] {
|
||||
continue
|
||||
}
|
||||
defs = append(defs, providers.ToolDef{
|
||||
Type: "function",
|
||||
Function: providers.FunctionDef{
|
||||
@@ -277,6 +288,65 @@ func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, in
|
||||
return defs
|
||||
}
|
||||
|
||||
// ── List Available Tools ────────────────────
|
||||
// GET /api/v1/tools
|
||||
//
|
||||
// Returns all registered server-side tools plus browser extension tools
|
||||
// for the current user. Used by the frontend to build the tools toggle menu.
|
||||
|
||||
type toolInfo struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
func (h *CompletionHandler) ListTools(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
// Server-side tools
|
||||
allDefs := tools.AllDefinitions()
|
||||
result := make([]toolInfo, 0, len(allDefs))
|
||||
for _, d := range allDefs {
|
||||
result = append(result, toolInfo{
|
||||
Name: d.Name,
|
||||
DisplayName: d.DisplayName,
|
||||
Description: d.Description,
|
||||
Category: d.Category,
|
||||
})
|
||||
}
|
||||
|
||||
// Browser extension tools
|
||||
if h.hub != nil && h.hub.IsConnected(userID) {
|
||||
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
|
||||
if err == nil {
|
||||
for _, ext := range exts {
|
||||
if ext.Tier != "browser" {
|
||||
continue
|
||||
}
|
||||
var manifest struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, t := range manifest.Tools {
|
||||
result = append(result, toolInfo{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Category: "browser",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
// ── Streaming Completion (SSE) with Tool Loop ──
|
||||
|
||||
const maxToolIterations = 10 // safety limit on tool call rounds
|
||||
|
||||
@@ -46,11 +46,12 @@ type editRequest struct {
|
||||
}
|
||||
|
||||
type regenerateRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
PresetID string `json:"preset_id,omitempty"`
|
||||
APIConfigID string `json:"provider_config_id,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
PresetID string `json:"preset_id,omitempty"`
|
||||
APIConfigID string `json:"provider_config_id,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
DisabledTools []string `json:"disabled_tools,omitempty"`
|
||||
}
|
||||
|
||||
type cursorRequest struct {
|
||||
@@ -455,7 +456,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
// Attach tool definitions (same as normal completion)
|
||||
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
||||
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
||||
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools)
|
||||
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools)
|
||||
}
|
||||
|
||||
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -22,6 +24,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
_ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init()
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools/search"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -89,6 +92,9 @@ func main() {
|
||||
|
||||
// Seed builtin extensions from disk (idempotent, version-aware)
|
||||
handlers.SeedBuiltinExtensions(stores, "extensions/builtin")
|
||||
|
||||
// Load search provider config from DB (defaults to DuckDuckGo if not set)
|
||||
loadSearchConfig(stores)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
@@ -226,6 +232,7 @@ func main() {
|
||||
// Chat Completions
|
||||
comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore)
|
||||
protected.POST("/chat/completions", comp.Complete)
|
||||
protected.GET("/tools", comp.ListTools)
|
||||
|
||||
// Summarize & Continue
|
||||
summarize := handlers.NewSummarizeHandler(stores, roleResolver)
|
||||
@@ -475,6 +482,25 @@ func main() {
|
||||
|
||||
// ── Vault CLI Commands ──────────────────────
|
||||
|
||||
// loadSearchConfig reads search provider config from global_config and applies it.
|
||||
// Falls back to DuckDuckGo (the default set in search package init) if not configured.
|
||||
func loadSearchConfig(stores store.Stores) {
|
||||
raw, err := stores.GlobalConfig.Get(context.Background(), "search_config")
|
||||
if err != nil || raw == nil {
|
||||
log.Println("🔍 Search: using default provider (DuckDuckGo)")
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(raw)
|
||||
var cfg search.Config
|
||||
if err := json.Unmarshal(b, &cfg); err != nil {
|
||||
log.Printf("⚠️ Failed to parse search config: %v", err)
|
||||
return
|
||||
}
|
||||
if err := search.ApplyConfig(cfg); err != nil {
|
||||
log.Printf("⚠️ Failed to apply search config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runVaultCommand(subcmd string) {
|
||||
cfg := config.Load()
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ type CalculatorTool struct{}
|
||||
func (t *CalculatorTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "calculator",
|
||||
DisplayName: "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.",
|
||||
Category: "utilities",
|
||||
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"}),
|
||||
|
||||
@@ -20,7 +20,9 @@ type DateTimeTool struct{}
|
||||
func (t *DateTimeTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "datetime",
|
||||
DisplayName: "Date & Time",
|
||||
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.",
|
||||
Category: "utilities",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"timezone": Prop("string", "IANA timezone name, e.g. 'America/New_York', 'UTC', 'Europe/London'. Defaults to UTC."),
|
||||
}, nil),
|
||||
|
||||
@@ -29,6 +29,8 @@ type NoteCreateTool struct{}
|
||||
func (t *NoteCreateTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "note_create",
|
||||
DisplayName: "Create",
|
||||
Category: "notes",
|
||||
Description: "Create a new note for the user. Use this to save information, insights, summaries, or anything the user wants to remember. Notes are persistent and searchable.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"title": Prop("string", "Title of the note"),
|
||||
@@ -98,6 +100,8 @@ type NoteSearchTool struct{}
|
||||
func (t *NoteSearchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "note_search",
|
||||
DisplayName: "Search",
|
||||
Category: "notes",
|
||||
Description: "Search the user's notes by keyword. Returns matching notes ranked by relevance with highlighted excerpts. Use this to find previously saved information.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"query": Prop("string", "Search query — natural language keywords"),
|
||||
@@ -180,6 +184,8 @@ type NoteUpdateTool struct{}
|
||||
func (t *NoteUpdateTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "note_update",
|
||||
DisplayName: "Update",
|
||||
Category: "notes",
|
||||
Description: "Update an existing note. Can replace content entirely, append to it, or prepend. Use note_search first to find the note ID.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"note_id": Prop("string", "UUID of the note to update (from note_search results)"),
|
||||
@@ -271,6 +277,8 @@ type NoteListTool struct{}
|
||||
func (t *NoteListTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "note_list",
|
||||
DisplayName: "List",
|
||||
Category: "notes",
|
||||
Description: "List the user's notes, optionally filtered by folder or tag. Shows titles and metadata without full content. Use note_search for keyword searching.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"folder": Prop("string", "Filter by folder path, e.g. '/projects/' (omit for all)"),
|
||||
|
||||
@@ -36,6 +36,22 @@ func AllDefinitions() []ToolDef {
|
||||
return defs
|
||||
}
|
||||
|
||||
// AllDefinitionsFiltered returns tool definitions excluding any names in the
|
||||
// disabled set. Used when the frontend sends a disabled_tools list.
|
||||
func AllDefinitionsFiltered(disabled map[string]bool) []ToolDef {
|
||||
if len(disabled) == 0 {
|
||||
return AllDefinitions()
|
||||
}
|
||||
defs := make([]ToolDef, 0, len(registry))
|
||||
for _, t := range registry {
|
||||
def := t.Definition()
|
||||
if !disabled[def.Name] {
|
||||
defs = append(defs, def)
|
||||
}
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// ── Execution ───────────────────────────────
|
||||
|
||||
// ExecuteCall runs a single tool call and returns a ToolResult.
|
||||
|
||||
152
server/tools/search/duckduckgo.go
Normal file
152
server/tools/search/duckduckgo.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterProvider(&DuckDuckGo{})
|
||||
// Default active provider — no API key needed
|
||||
_ = SetActive("duckduckgo")
|
||||
}
|
||||
|
||||
// DuckDuckGo implements Provider using DDG's HTML lite endpoint.
|
||||
// No API key required. Returns organic web results.
|
||||
type DuckDuckGo struct{}
|
||||
|
||||
func (d *DuckDuckGo) Name() string { return "duckduckgo" }
|
||||
|
||||
func (d *DuckDuckGo) Search(ctx context.Context, query string, maxResults int) ([]SearchResult, error) {
|
||||
if maxResults <= 0 {
|
||||
maxResults = 5
|
||||
}
|
||||
if maxResults > 10 {
|
||||
maxResults = 10
|
||||
}
|
||||
|
||||
endpoint := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "ChatSwitchboard/1.0 (Web Search Tool)")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Limit body read to 512KB
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
return parseDDGHTML(string(body), maxResults), nil
|
||||
}
|
||||
|
||||
// parseDDGHTML extracts search results from DuckDuckGo's HTML lite page.
|
||||
// Results are in <a class="result__a"> tags with snippets in <a class="result__snippet">.
|
||||
func parseDDGHTML(htmlContent string, maxResults int) []SearchResult {
|
||||
doc, err := html.Parse(strings.NewReader(htmlContent))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []SearchResult
|
||||
|
||||
// Walk the DOM looking for result blocks
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if len(results) >= maxResults {
|
||||
return
|
||||
}
|
||||
|
||||
if n.Type == html.ElementNode && n.Data == "a" {
|
||||
class := getAttr(n, "class")
|
||||
|
||||
if strings.Contains(class, "result__a") {
|
||||
href := getAttr(n, "href")
|
||||
title := textContent(n)
|
||||
// DDG wraps URLs through a redirect; extract the actual URL
|
||||
actualURL := extractDDGURL(href)
|
||||
if actualURL != "" && title != "" {
|
||||
results = append(results, SearchResult{
|
||||
Title: strings.TrimSpace(title),
|
||||
URL: actualURL,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(class, "result__snippet") {
|
||||
snippet := strings.TrimSpace(textContent(n))
|
||||
// Attach snippet to the most recent result
|
||||
if len(results) > 0 && results[len(results)-1].Snippet == "" {
|
||||
results[len(results)-1].Snippet = snippet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// extractDDGURL extracts the actual URL from DDG's redirect wrapper.
|
||||
// DDG links look like: //duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com&rut=...
|
||||
func extractDDGURL(href string) string {
|
||||
if strings.Contains(href, "uddg=") {
|
||||
u, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return href
|
||||
}
|
||||
uddg := u.Query().Get("uddg")
|
||||
if uddg != "" {
|
||||
return uddg
|
||||
}
|
||||
}
|
||||
// Direct URL
|
||||
if strings.HasPrefix(href, "http") {
|
||||
return href
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getAttr(n *html.Node, key string) string {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func textContent(n *html.Node) string {
|
||||
if n.Type == html.TextNode {
|
||||
return n.Data
|
||||
}
|
||||
var sb strings.Builder
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
sb.WriteString(textContent(c))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
103
server/tools/search/provider.go
Normal file
103
server/tools/search/provider.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SearchResult represents a single web search result.
|
||||
type SearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Snippet string `json:"snippet"`
|
||||
}
|
||||
|
||||
// Provider is the interface for web search backends.
|
||||
type Provider interface {
|
||||
// Search performs a web search and returns up to maxResults results.
|
||||
Search(ctx context.Context, query string, maxResults int) ([]SearchResult, error)
|
||||
|
||||
// Name returns the provider identifier (e.g. "duckduckgo", "searxng").
|
||||
Name() string
|
||||
}
|
||||
|
||||
// ── Provider Registry ───────────────────────
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
providers = map[string]Provider{}
|
||||
active string
|
||||
)
|
||||
|
||||
// RegisterProvider adds a search provider to the registry.
|
||||
func RegisterProvider(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
providers[p.Name()] = p
|
||||
log.Printf("🔍 Registered search provider: %s", p.Name())
|
||||
}
|
||||
|
||||
// SetActive sets the active search provider by name.
|
||||
func SetActive(name string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if _, ok := providers[name]; !ok {
|
||||
return fmt.Errorf("unknown search provider: %s", name)
|
||||
}
|
||||
active = name
|
||||
log.Printf("🔍 Active search provider: %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Active returns the currently active provider, or nil if none configured.
|
||||
func Active() Provider {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
if active == "" {
|
||||
return nil
|
||||
}
|
||||
return providers[active]
|
||||
}
|
||||
|
||||
// Available returns the names of all registered providers.
|
||||
func Available() []string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
names := make([]string, 0, len(providers))
|
||||
for name := range providers {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Config holds the persisted search configuration.
|
||||
type Config struct {
|
||||
Provider string `json:"provider"` // "duckduckgo" or "searxng"
|
||||
Endpoint string `json:"endpoint"` // SearXNG endpoint URL
|
||||
APIKey string `json:"api_key"` // SearXNG API key (optional)
|
||||
MaxResults int `json:"max_results"` // default results per search
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default search config (DuckDuckGo, no key needed).
|
||||
func DefaultConfig() Config {
|
||||
return Config{Provider: "duckduckgo", MaxResults: 5}
|
||||
}
|
||||
|
||||
// ApplyConfig applies a Config to the active provider registry.
|
||||
// Called on startup and when admin saves settings.
|
||||
func ApplyConfig(cfg Config) error {
|
||||
if cfg.Provider == "" {
|
||||
cfg.Provider = "duckduckgo"
|
||||
}
|
||||
|
||||
// Configure SearXNG if selected
|
||||
mu.RLock()
|
||||
if sxng, ok := providers["searxng"].(*SearXNG); ok {
|
||||
sxng.Configure(cfg.Endpoint, cfg.APIKey)
|
||||
}
|
||||
mu.RUnlock()
|
||||
|
||||
return SetActive(cfg.Provider)
|
||||
}
|
||||
116
server/tools/search/searxng.go
Normal file
116
server/tools/search/searxng.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SearXNG implements Provider using a self-hosted SearXNG instance.
|
||||
// Suitable for airgapped deployments. Requires a configured endpoint.
|
||||
type SearXNG struct {
|
||||
endpoint string // base URL, e.g. "https://search.internal.example.com"
|
||||
apiKey string // optional API key (sent as X-API-Key header)
|
||||
}
|
||||
|
||||
func (s *SearXNG) Name() string { return "searxng" }
|
||||
|
||||
// Configure sets the SearXNG endpoint and optional API key.
|
||||
func (s *SearXNG) Configure(endpoint, apiKey string) {
|
||||
s.endpoint = endpoint
|
||||
s.apiKey = apiKey
|
||||
}
|
||||
|
||||
// Endpoint returns the configured endpoint (for admin display).
|
||||
func (s *SearXNG) Endpoint() string { return s.endpoint }
|
||||
|
||||
func (s *SearXNG) Search(ctx context.Context, query string, maxResults int) ([]SearchResult, error) {
|
||||
if s.endpoint == "" {
|
||||
return nil, fmt.Errorf("SearXNG endpoint not configured")
|
||||
}
|
||||
|
||||
if maxResults <= 0 {
|
||||
maxResults = 5
|
||||
}
|
||||
if maxResults > 10 {
|
||||
maxResults = 10
|
||||
}
|
||||
|
||||
// Build request URL: /search?q=...&format=json
|
||||
u, err := url.Parse(s.endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid SearXNG endpoint: %w", err)
|
||||
}
|
||||
u.Path = "/search"
|
||||
q := u.Query()
|
||||
q.Set("q", query)
|
||||
q.Set("format", "json")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "ChatSwitchboard/1.0 (Web Search Tool)")
|
||||
if s.apiKey != "" {
|
||||
req.Header.Set("X-API-Key", s.apiKey)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SearXNG request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("SearXNG returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read SearXNG response: %w", err)
|
||||
}
|
||||
|
||||
return parseSearXNGJSON(body, maxResults)
|
||||
}
|
||||
|
||||
// SearXNG JSON response format
|
||||
type searxngResponse struct {
|
||||
Results []struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"` // snippet
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func parseSearXNGJSON(data []byte, maxResults int) ([]SearchResult, error) {
|
||||
var resp searxngResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("parse SearXNG response: %w", err)
|
||||
}
|
||||
|
||||
results := make([]SearchResult, 0, maxResults)
|
||||
for _, r := range resp.Results {
|
||||
if len(results) >= maxResults {
|
||||
break
|
||||
}
|
||||
if r.URL == "" {
|
||||
continue
|
||||
}
|
||||
results = append(results, SearchResult{
|
||||
Title: r.Title,
|
||||
URL: r.URL,
|
||||
Snippet: r.Content,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterProvider(&SearXNG{})
|
||||
}
|
||||
@@ -11,8 +11,10 @@ import (
|
||||
// format by providers. The Parameters field uses JSON Schema.
|
||||
type ToolDef struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"` // human-readable label for toggle UI
|
||||
Description string `json:"description"`
|
||||
Parameters json.RawMessage `json:"parameters"` // JSON Schema object
|
||||
Parameters json.RawMessage `json:"parameters"` // JSON Schema object
|
||||
Category string `json:"category,omitempty"` // UI grouping: search, notes, utilities, browser
|
||||
}
|
||||
|
||||
// ── Tool Call (from LLM response) ───────────
|
||||
|
||||
308
server/tools/urlfetch.go
Normal file
308
server/tools/urlfetch.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(&URLFetchTool{})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// url_fetch
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type URLFetchTool struct{}
|
||||
|
||||
func (t *URLFetchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "url_fetch",
|
||||
DisplayName: "Fetch URL",
|
||||
Description: "Fetch and extract readable text content from a web page URL. Use this to read articles, documentation, or any web page the user shares or that appeared in search results. Returns the page title and extracted text.",
|
||||
Category: "search",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"url": Prop("string", "Full URL to fetch (must start with http:// or https://)"),
|
||||
"max_length": Prop("integer", "Maximum content length in characters (default 8000, max 16000)"),
|
||||
}, []string{"url"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *URLFetchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
URL string `json:"url"`
|
||||
MaxLength int `json:"max_length"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.URL == "" {
|
||||
return "", fmt.Errorf("url is required")
|
||||
}
|
||||
if !strings.HasPrefix(args.URL, "http://") && !strings.HasPrefix(args.URL, "https://") {
|
||||
return "", fmt.Errorf("url must start with http:// or https://")
|
||||
}
|
||||
|
||||
if args.MaxLength <= 0 {
|
||||
args.MaxLength = 8000
|
||||
}
|
||||
if args.MaxLength > 16000 {
|
||||
args.MaxLength = 16000
|
||||
}
|
||||
|
||||
// SSRF protection: resolve hostname and block private IPs
|
||||
if err := checkSSRF(args.URL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Fetch with timeout
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", args.URL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "ChatSwitchboard/1.0 (URL Fetch Tool)")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,text/plain")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "text/html") && !strings.Contains(ct, "text/plain") &&
|
||||
!strings.Contains(ct, "application/xhtml") && !strings.Contains(ct, "application/json") {
|
||||
return "", fmt.Errorf("unsupported content type: %s (expected HTML or text)", ct)
|
||||
}
|
||||
|
||||
// Read body with limit (1MB raw)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
// Plain text or JSON → return as-is (truncated)
|
||||
if strings.Contains(ct, "text/plain") || strings.Contains(ct, "application/json") {
|
||||
content := string(body)
|
||||
content = truncateUTF8(content, args.MaxLength)
|
||||
result := map[string]interface{}{
|
||||
"url": args.URL,
|
||||
"content_type": ct,
|
||||
"content": content,
|
||||
"truncated": len(body) > args.MaxLength,
|
||||
}
|
||||
b, _ := json.Marshal(result)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// HTML → extract readable text
|
||||
title, text := extractReadableHTML(string(body))
|
||||
text = truncateUTF8(text, args.MaxLength)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"url": args.URL,
|
||||
"title": title,
|
||||
"content": text,
|
||||
"truncated": utf8.RuneCountInString(string(body)) > args.MaxLength,
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(result)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// ── SSRF Protection ─────────────────────────
|
||||
|
||||
func checkSSRF(rawURL string) error {
|
||||
// Parse hostname
|
||||
host := rawURL
|
||||
if idx := strings.Index(rawURL, "://"); idx >= 0 {
|
||||
host = rawURL[idx+3:]
|
||||
}
|
||||
if idx := strings.IndexAny(host, ":/"); idx >= 0 {
|
||||
host = host[:idx]
|
||||
}
|
||||
|
||||
// Resolve DNS
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve host: %s", host)
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if isPrivateIP(ip) {
|
||||
return fmt.Errorf("blocked: %s resolves to private IP %s", host, ip.String())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
privateRanges := []struct {
|
||||
network *net.IPNet
|
||||
}{
|
||||
{mustParseCIDR("10.0.0.0/8")},
|
||||
{mustParseCIDR("172.16.0.0/12")},
|
||||
{mustParseCIDR("192.168.0.0/16")},
|
||||
{mustParseCIDR("127.0.0.0/8")},
|
||||
{mustParseCIDR("169.254.0.0/16")},
|
||||
{mustParseCIDR("::1/128")},
|
||||
{mustParseCIDR("fc00::/7")},
|
||||
{mustParseCIDR("fe80::/10")},
|
||||
}
|
||||
for _, r := range privateRanges {
|
||||
if r.network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mustParseCIDR(s string) *net.IPNet {
|
||||
_, net, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
panic("invalid CIDR: " + s)
|
||||
}
|
||||
return net
|
||||
}
|
||||
|
||||
// ── HTML → Text Extraction ──────────────────
|
||||
|
||||
// extractReadableHTML extracts the page title and readable text from HTML.
|
||||
// Strips scripts, styles, nav, header, footer elements.
|
||||
func extractReadableHTML(htmlContent string) (title, text string) {
|
||||
doc, err := html.Parse(strings.NewReader(htmlContent))
|
||||
if err != nil {
|
||||
return "", htmlContent
|
||||
}
|
||||
|
||||
// Extract <title>
|
||||
title = findTitle(doc)
|
||||
|
||||
// Extract text from body, skipping non-content elements
|
||||
var sb strings.Builder
|
||||
extractText(doc, &sb, false)
|
||||
text = cleanText(sb.String())
|
||||
|
||||
return title, text
|
||||
}
|
||||
|
||||
func findTitle(n *html.Node) string {
|
||||
if n.Type == html.ElementNode && n.Data == "title" {
|
||||
return strings.TrimSpace(textContentNode(n))
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if t := findTitle(c); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// skipTags are elements whose content is not readable text.
|
||||
var skipTags = map[string]bool{
|
||||
"script": true, "style": true, "noscript": true,
|
||||
"nav": true, "header": true, "footer": true,
|
||||
"aside": true, "svg": true, "form": true,
|
||||
"iframe": true, "button": true, "select": true,
|
||||
}
|
||||
|
||||
func extractText(n *html.Node, sb *strings.Builder, inBody bool) {
|
||||
if n.Type == html.ElementNode {
|
||||
if skipTags[n.Data] {
|
||||
return
|
||||
}
|
||||
if n.Data == "body" {
|
||||
inBody = true
|
||||
}
|
||||
}
|
||||
|
||||
if inBody && n.Type == html.TextNode {
|
||||
text := strings.TrimSpace(n.Data)
|
||||
if text != "" {
|
||||
sb.WriteString(text)
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
// Block elements get a newline
|
||||
if n.Type == html.ElementNode {
|
||||
switch n.Data {
|
||||
case "p", "div", "br", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"li", "tr", "blockquote", "pre", "article", "section":
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
extractText(c, sb, inBody)
|
||||
}
|
||||
}
|
||||
|
||||
func textContentNode(n *html.Node) string {
|
||||
if n.Type == html.TextNode {
|
||||
return n.Data
|
||||
}
|
||||
var sb strings.Builder
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
sb.WriteString(textContentNode(c))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// cleanText collapses multiple spaces/newlines into single separators.
|
||||
func cleanText(s string) string {
|
||||
var sb strings.Builder
|
||||
prevNewline := false
|
||||
prevSpace := false
|
||||
for _, r := range s {
|
||||
if r == '\n' {
|
||||
if !prevNewline {
|
||||
sb.WriteRune('\n')
|
||||
}
|
||||
prevNewline = true
|
||||
prevSpace = false
|
||||
} else if r == ' ' || r == '\t' {
|
||||
if !prevSpace && !prevNewline {
|
||||
sb.WriteRune(' ')
|
||||
}
|
||||
prevSpace = true
|
||||
} else {
|
||||
sb.WriteRune(r)
|
||||
prevNewline = false
|
||||
prevSpace = false
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
func truncateUTF8(s string, maxChars int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxChars {
|
||||
return s
|
||||
}
|
||||
return string(runes[:maxChars]) + "\n[content truncated]"
|
||||
}
|
||||
73
server/tools/websearch.go
Normal file
73
server/tools/websearch.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools/search"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(&WebSearchTool{})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// web_search
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type WebSearchTool struct{}
|
||||
|
||||
func (t *WebSearchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "web_search",
|
||||
DisplayName: "Search",
|
||||
Description: "Search the web for current information. Use this when you need up-to-date facts, news, or information you're not confident about. Returns titles, URLs, and text snippets from web pages.",
|
||||
Category: "search",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"query": Prop("string", "Search query — be specific and concise"),
|
||||
"max_results": Prop("integer", "Maximum number of results to return (1-10, default 5)"),
|
||||
}, []string{"query"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WebSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Query string `json:"query"`
|
||||
MaxResults int `json:"max_results"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.Query == "" {
|
||||
return "", fmt.Errorf("query is required")
|
||||
}
|
||||
|
||||
if args.MaxResults <= 0 {
|
||||
args.MaxResults = 5
|
||||
}
|
||||
if args.MaxResults > 10 {
|
||||
args.MaxResults = 10
|
||||
}
|
||||
|
||||
provider := search.Active()
|
||||
if provider == nil {
|
||||
return "", fmt.Errorf("no search provider configured")
|
||||
}
|
||||
|
||||
results, err := provider.Search(ctx, args.Query, args.MaxResults)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"provider": provider.Name(),
|
||||
"query": args.Query,
|
||||
"result_count": len(results),
|
||||
"results": results,
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(response)
|
||||
return string(b), nil
|
||||
}
|
||||
Reference in New Issue
Block a user