Changeset 0.13.1 (#65)

This commit is contained in:
2026-02-25 23:56:27 +00:00
parent 8292a6efa8
commit 113b98ace4
24 changed files with 1565 additions and 24 deletions

View File

@@ -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"}),

View File

@@ -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),

View File

@@ -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)"),

View File

@@ -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.

View 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()
}

View 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)
}

View 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{})
}

View File

@@ -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
View 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
View 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
}