Changeset 0.13.1 (#65)
This commit is contained in:
231
DESIGN-0.13.1.md
Normal file
231
DESIGN-0.13.1.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# DESIGN-0.13.1 — Web Search + URL Fetch + Tool Toggle
|
||||
|
||||
## Overview
|
||||
|
||||
Built-in `web_search` and `url_fetch` tools using the existing tool framework (v0.11.0),
|
||||
plus a chat-bar tools toggle menu so users can enable/disable tool categories per-session.
|
||||
|
||||
Depends on: tool framework (v0.11.0), admin panel (v0.13.0).
|
||||
|
||||
---
|
||||
|
||||
## 1. Tool Categories
|
||||
|
||||
Add a `Category` field to `ToolDef` so tools self-declare their group.
|
||||
The frontend uses categories for the toggle menu; the backend ignores them.
|
||||
|
||||
| Category | Tools | Default |
|
||||
|-----------|---------------------------------------------|---------|
|
||||
| search | `web_search`, `url_fetch` | ON |
|
||||
| notes | `note_create`, `note_search`, `note_update`, `note_list` | ON |
|
||||
| utilities | `datetime`, `calculator` | ON |
|
||||
| browser | (extension-defined: `js_eval`, `regex_test`)| ON |
|
||||
|
||||
```go
|
||||
type ToolDef struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"` // human-readable label for UI
|
||||
Description string `json:"description"`
|
||||
Parameters json.RawMessage `json:"parameters"`
|
||||
Category string `json:"category,omitempty"` // NEW
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Per-Request Tool Filtering
|
||||
|
||||
### Request field
|
||||
```json
|
||||
{ "disabled_tools": ["web_search", "url_fetch"] }
|
||||
```
|
||||
|
||||
### Backend filter
|
||||
`buildToolDefs()` skips tools whose name appears in `disabled_tools`.
|
||||
Zero API changes — the field is optional, empty = all tools enabled.
|
||||
|
||||
### Frontend state
|
||||
`localStorage` key: `cs-disabled-tools` → JSON array of tool names.
|
||||
The tools toggle menu flips categories on/off which maps to individual tool names.
|
||||
|
||||
## 3. web_search Tool
|
||||
|
||||
### Search Provider Interface
|
||||
```go
|
||||
type SearchProvider interface {
|
||||
Search(ctx context.Context, query string, maxResults int) ([]SearchResult, error)
|
||||
Name() string
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Snippet string `json:"snippet"`
|
||||
}
|
||||
```
|
||||
|
||||
### DuckDuckGo Provider (default — no API key)
|
||||
Uses the DuckDuckGo HTML search endpoint. No rate-limit key required.
|
||||
Falls back gracefully if blocked (returns error result to LLM, doesn't crash).
|
||||
|
||||
### SearXNG Provider (self-hosted)
|
||||
Configurable endpoint + optional API key. JSON API (`/search?format=json`).
|
||||
Good fit for airgapped deployments.
|
||||
|
||||
### Admin Config
|
||||
Stored in `global_config` table (existing):
|
||||
- `search_provider`: `"duckduckgo"` | `"searxng"` (default: `"duckduckgo"`)
|
||||
- `search_endpoint`: SearXNG URL (only used when provider=searxng)
|
||||
- `search_max_results`: `5` (default)
|
||||
|
||||
Admin UI: System → Settings section (new "Search" subsection).
|
||||
|
||||
### Tool Schema
|
||||
```json
|
||||
{
|
||||
"name": "web_search",
|
||||
"description": "Search the web for current information. Returns titles, URLs, and snippets.",
|
||||
"category": "search",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Search query" },
|
||||
"max_results": { "type": "integer", "description": "Max results (1-10, default 5)" }
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. url_fetch Tool
|
||||
|
||||
HTTP GET with content extraction. Reuses the text extraction pattern
|
||||
from file handling (v0.12.0) where possible.
|
||||
|
||||
### Behavior
|
||||
1. HTTP GET with reasonable timeout (15s) and User-Agent
|
||||
2. Follow redirects (max 3)
|
||||
3. Extract readable text content (strip HTML tags, scripts, styles)
|
||||
4. Truncate to ~8000 chars to avoid flooding the context
|
||||
5. Return title + extracted text + metadata
|
||||
|
||||
### Safety
|
||||
- Respect robots.txt? No — the LLM is fetching on behalf of the user, not crawling.
|
||||
- Block private/internal IPs (10.x, 192.168.x, 127.x, ::1) to prevent SSRF.
|
||||
- Configurable domain allowlist/blocklist in admin settings (future).
|
||||
|
||||
### Tool Schema
|
||||
```json
|
||||
{
|
||||
"name": "url_fetch",
|
||||
"description": "Fetch and extract readable text content from a URL.",
|
||||
"category": "search",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": { "type": "string", "description": "URL to fetch" },
|
||||
"max_length": { "type": "integer", "description": "Max content length in chars (default 8000)" }
|
||||
},
|
||||
"required": ["url"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Frontend: Tools Toggle Menu
|
||||
|
||||
### UI Position
|
||||
Chat input bar, left side, next to the attach button. Wrench/tool icon.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ 📎 🔧 [message input...] [Send]│
|
||||
└──────────────────────────────────────┘
|
||||
▲
|
||||
┌────────────────┐
|
||||
│ ☑ Web Search │
|
||||
│ ☑ Notes │
|
||||
│ ☑ Utilities │
|
||||
│ ☑ Extensions │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
### Behavior
|
||||
- Click tool icon → popup above (using existing popup-menu pattern)
|
||||
- Each category row has a toggle checkbox + expand chevron
|
||||
- **Category toggle**: master on/off for all tools in the category
|
||||
- **Expand chevron**: shows individual tool rows indented below
|
||||
- **Tri-state checkbox**: if some tools in a category are disabled, category shows indeterminate (─)
|
||||
- Toggling a category adds/removes all its tool names from `cs-disabled-tools`
|
||||
- Toggling an individual tool adds/removes just that name
|
||||
- State persists across page reloads via localStorage
|
||||
- Disabled tools sent as `disabled_tools` array in completion request
|
||||
- Tool icon shows visual indicator when any category is disabled
|
||||
- If model doesn't support tool_calling, icon is hidden/grayed
|
||||
|
||||
```
|
||||
┌────────────────────────┐
|
||||
│ ☑ 🔍 Web Search › │ ← click › to expand
|
||||
│ ☑ Search │
|
||||
│ ☑ Fetch URL │
|
||||
│ ☑ 📝 Notes › │
|
||||
│ ☐ 🧮 Utilities › │ ← category off = all children off
|
||||
│ ☑ 🧩 Extensions › │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
### Tool Manifest
|
||||
Frontend needs to know the category mapping and display names.
|
||||
|
||||
**API endpoint** — `GET /api/v1/tools` returns `[{name, display_name, category, description}]`
|
||||
The frontend caches this on login. `display_name` provides human-readable labels
|
||||
(e.g. "Search" instead of "web_search", "Create" instead of "note_create").
|
||||
|
||||
### New API Endpoint
|
||||
```
|
||||
GET /api/v1/tools → [{name, description, category}]
|
||||
```
|
||||
Returns server-side tools + browser extension tool schemas.
|
||||
Used by frontend to build the toggle menu dynamically.
|
||||
|
||||
## 6. File Structure
|
||||
|
||||
### New backend files
|
||||
- `server/tools/websearch.go` — web_search tool + search provider interface
|
||||
- `server/tools/urlfetch.go` — url_fetch tool
|
||||
- `server/tools/search/duckduckgo.go` — DuckDuckGo provider
|
||||
- `server/tools/search/searxng.go` — SearXNG provider
|
||||
- `server/tools/search/provider.go` — interface + registry
|
||||
|
||||
### Modified backend files
|
||||
- `server/tools/types.go` — add Category to ToolDef
|
||||
- `server/tools/registry.go` — add AllDefinitionsFiltered(disabled)
|
||||
- `server/handlers/completion.go` — accept disabled_tools, filter in buildToolDefs
|
||||
- `server/handlers/messages.go` — same filtering
|
||||
- `server/handlers/routes.go` — add GET /api/v1/tools endpoint
|
||||
- `server/tools/datetime.go` — add Category: "utilities"
|
||||
- `server/tools/calculator.go` — add Category: "utilities"
|
||||
- `server/tools/notes.go` — add Category: "notes"
|
||||
|
||||
### New/modified frontend files
|
||||
- `src/index.html` — tools toggle button in input-wrap
|
||||
- `src/css/styles.css` — tools popup styling
|
||||
- `src/js/chat.js` — send disabled_tools in completion, wire toggle
|
||||
- `src/js/api.js` — add disabled_tools param, add getTools() method
|
||||
|
||||
## 7. Phased Delivery
|
||||
|
||||
**Phase 1: Backend tools + filtering**
|
||||
- ToolDef.Category, disabled_tools filtering
|
||||
- web_search + url_fetch tools
|
||||
- DuckDuckGo provider
|
||||
- GET /api/v1/tools endpoint
|
||||
- Tests
|
||||
|
||||
**Phase 2: Frontend toggle**
|
||||
- Tools toggle popup menu
|
||||
- localStorage persistence
|
||||
- Completion request wiring
|
||||
- CSS
|
||||
|
||||
**Phase 3: Admin config + SearXNG**
|
||||
- Search provider settings in admin panel
|
||||
- SearXNG provider implementation
|
||||
- Domain filtering
|
||||
89
ROADMAP.md
89
ROADMAP.md
@@ -36,8 +36,13 @@ v0.11.0 Extension Foundation (browser tier) ✅
|
||||
│
|
||||
┌───────┴──────────────┐
|
||||
│ │
|
||||
v0.12.0 File Handling v0.13.0 Web Search
|
||||
+ Vision + Tools Expansion
|
||||
v0.12.0 File Handling v0.13.0 Admin Panel
|
||||
+ Vision ✅ Refactor (fullscreen)
|
||||
│ │
|
||||
│ v0.13.1 Web Search
|
||||
│ + url_fetch
|
||||
│ │
|
||||
└───────┬──────────────┘
|
||||
│
|
||||
v0.14.0 Knowledge Bases (embedding role + file storage + pgvector)
|
||||
│
|
||||
@@ -426,7 +431,7 @@ OpenAI streaming usage race fix.
|
||||
|
||||
---
|
||||
|
||||
## v0.10.2 — Summarize & Continue + Role Cleanup
|
||||
## v0.10.2 — Summarize & Continue + Role Cleanup ✅
|
||||
|
||||
First consumer of the utility model role. User-triggered conversation
|
||||
compaction — not automatic (that's v0.15.0).
|
||||
@@ -603,15 +608,81 @@ File input into chat — table stakes for serious use.
|
||||
|
||||
---
|
||||
|
||||
## v0.13.0 — Web Search + Tools Expansion
|
||||
## v0.13.0 — Admin Panel Refactor
|
||||
|
||||
The admin modal has grown to 12 tabs in a horizontal overflow strip. Adding
|
||||
search provider config, KB management, and workflow settings would make it
|
||||
worse. Replace the modal with a fullscreen admin panel.
|
||||
|
||||
Depends on: nothing (frontend-only, no API changes).
|
||||
|
||||
**Layout** (modeled after Open WebUI's admin panel):
|
||||
- Fullscreen overlay (or `/admin` route) replaces the modal
|
||||
- Top bar: 4 category tabs (horizontal)
|
||||
- Left sidebar: section tabs within the active category
|
||||
- Main content: full viewport width for the active section
|
||||
|
||||
**Category → Section mapping:**
|
||||
|
||||
| Category | Sections | Current tabs absorbed |
|
||||
|----------|----------|---------------------|
|
||||
| **People** | Users, Teams | Users, Teams |
|
||||
| **AI** | Providers, Models, Personas, Roles | Providers, Models, Presets, Roles |
|
||||
| **System** | Settings, Storage, Extensions | Settings, Storage, Extensions |
|
||||
| **Monitoring** | Usage, Audit, Stats | Usage, Audit, Stats |
|
||||
|
||||
- [x] Fullscreen admin layout: top categories + left sidebar sections
|
||||
- [x] Migrate all 12 existing tab contents into new layout (no API changes)
|
||||
- [x] Category → section routing with URL hash or state (e.g. `#admin/ai/models`)
|
||||
- [x] Responsive: sidebar collapses on narrow viewports
|
||||
- [x] Keyboard shortcut: `Esc` returns to chat
|
||||
- [x] Admin button in sidebar opens fullscreen panel (replaces modal trigger)
|
||||
- [ ] Remove old `adminModal` HTML + CSS
|
||||
- [x] Preserve all existing `loadAdmin*()` functions — new layout calls same loaders
|
||||
- [x] Roles moved from People → AI category
|
||||
- [x] Presets → Personas label rename (UI only, API/data unchanged)
|
||||
- [x] Banner-aware: admin panel respects `--banner-top/bottom-height`
|
||||
- [x] CSS design token cleanup: ghost variables, hardcoded colors → tokens
|
||||
- [x] Global form element theming (bare selects/inputs inherit theme)
|
||||
- [x] Zoom scaling: admin panel + side panel respect appearance slider
|
||||
- [x] CI fix: `TruncateAll()` includes `platform_policies` + `global_config`
|
||||
|
||||
---
|
||||
|
||||
## v0.13.1 — Web Search + URL Fetch
|
||||
|
||||
Built-in tools that extend the tool framework shipped in v0.11.0.
|
||||
Admin configuration lives in the new admin panel (System → Settings or AI → Providers).
|
||||
|
||||
- [ ] `web_search` tool: search provider abstraction (SearXNG, Brave, DuckDuckGo)
|
||||
- [ ] `url_fetch` tool: retrieve and extract content from URLs
|
||||
- [ ] Sidecar or direct HTTP from backend (configurable)
|
||||
- [ ] Results injected into context
|
||||
- [ ] "Research X and save to my notes" workflow
|
||||
Depends on: admin panel refactor (v0.13.0) for config UI, tool framework (v0.11.0).
|
||||
|
||||
**Search provider abstraction:**
|
||||
- [x] `SearchProvider` interface: `Search(ctx, query, maxResults) → []SearchResult`
|
||||
- [x] DuckDuckGo provider (no API key required — default, works out of the box)
|
||||
- [x] SearXNG provider (self-hosted, fits airgapped story, configurable endpoint)
|
||||
- [x] Admin config: select active provider, set endpoint/API key, result count
|
||||
- [x] Provider config stored in `global_config` as `search_config` key
|
||||
- [x] Runtime apply on save (no restart required) + load on startup
|
||||
|
||||
**Tools:**
|
||||
- [x] `web_search` tool: calls active search provider, returns title + snippet + URL
|
||||
- [x] `url_fetch` tool: HTTP GET + content extraction (HTML→text, SSRF protection)
|
||||
- [x] Results injected into context as tool results (existing tool framework handles this)
|
||||
- [ ] Rate limiting / concurrency control per-provider
|
||||
|
||||
**Tool categories + toggle:**
|
||||
- [x] `ToolDef.Category` field: search, notes, utilities, browser
|
||||
- [x] `disabled_tools[]` per-request filtering (completion + regen + edit paths)
|
||||
- [x] `GET /api/v1/tools` endpoint: returns `[{name, description, category}]`
|
||||
- [x] Chat-bar tools toggle popup: category-level enable/disable
|
||||
- [x] localStorage persistence of disabled state
|
||||
- [x] `ToolDef.DisplayName` field: human-readable name for UI (e.g. "Search" not "web_search")
|
||||
- [x] Sub-menu per category: expand-in-place to show individual tools
|
||||
- [x] Tri-state category checkbox: all on / all off / indeterminate (partial)
|
||||
- [x] Per-tool toggle within categories (child checkboxes)
|
||||
|
||||
**Future providers** (abstraction supports but not in v0.13.1):
|
||||
- Brave Search (API key), Tavily, Kagi, SerpAPI, Google PSE
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1948,6 +1948,55 @@ select option { background: var(--bg-surface); color: var(--text); }
|
||||
.popup-menu-hint { margin-left: auto; font-size: 11px; color: var(--text-3); }
|
||||
.popup-menu-divider { height: 1px; background: var(--border); margin: 4px 8px; }
|
||||
|
||||
/* ── Tools Toggle ────────────────────────── */
|
||||
|
||||
.tools-toggle-wrap { position: relative; }
|
||||
.tools-toggle-btn { padding: 6px 4px 12px 4px; }
|
||||
.tools-toggle-btn.has-disabled { color: var(--text-3); }
|
||||
.tools-toggle-btn.has-disabled::after {
|
||||
content: ''; position: absolute; top: 4px; right: 4px;
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--warning);
|
||||
}
|
||||
.tools-popup { bottom: 100%; left: 0; margin-bottom: 8px; min-width: 210px; }
|
||||
.tools-popup-category, .tools-popup-tool {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px; border-radius: var(--radius);
|
||||
cursor: pointer; font-size: 13px; color: var(--text-2);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.tools-popup-category:hover, .tools-popup-tool:hover { background: var(--bg-hover); }
|
||||
.tools-popup-tool { padding-left: 36px; font-size: 12px; } /* indented children */
|
||||
.tools-popup-check {
|
||||
width: 16px; height: 16px; border-radius: 3px;
|
||||
border: 1.5px solid var(--border); flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background var(--transition), border-color var(--transition);
|
||||
}
|
||||
/* Fully enabled */
|
||||
.tools-popup-category.enabled .tools-popup-check,
|
||||
.tools-popup-tool.enabled .tools-popup-check {
|
||||
background: var(--accent); border-color: var(--accent);
|
||||
}
|
||||
.tools-popup-category.enabled .tools-popup-check::after,
|
||||
.tools-popup-tool.enabled .tools-popup-check::after {
|
||||
content: '✓'; color: var(--text-on-color); font-size: 11px; font-weight: 700;
|
||||
}
|
||||
/* Partial (indeterminate) */
|
||||
.tools-popup-category.partial .tools-popup-check {
|
||||
background: var(--accent-dim); border-color: var(--accent);
|
||||
}
|
||||
.tools-popup-category.partial .tools-popup-check::after {
|
||||
content: '─'; color: var(--accent); font-size: 12px; font-weight: 700;
|
||||
}
|
||||
.tools-popup-label { flex: 1; }
|
||||
.tools-popup-expand {
|
||||
font-size: 14px; color: var(--text-3); cursor: pointer;
|
||||
transition: transform var(--transition); padding: 0 2px;
|
||||
user-select: none;
|
||||
}
|
||||
.tools-popup-expand.open { transform: rotate(90deg); }
|
||||
|
||||
/* ── Responsive ──────────────────────────── */
|
||||
|
||||
.mobile-menu-btn {
|
||||
|
||||
@@ -153,6 +153,12 @@
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
|
||||
</button>
|
||||
<input type="file" id="fileInput" multiple style="display:none">
|
||||
<div class="tools-toggle-wrap" style="display:none">
|
||||
<button class="action-btn tools-toggle-btn" id="toolsToggleBtn" title="Tools">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
</button>
|
||||
<div class="popup-menu tools-popup" id="toolsPopup"></div>
|
||||
</div>
|
||||
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
|
||||
<div class="input-actions">
|
||||
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
|
||||
@@ -720,6 +726,30 @@
|
||||
<div class="banner-preview" id="bannerPreview">PREVIEW</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-section">
|
||||
<h3>Web Search</h3>
|
||||
<div class="form-group">
|
||||
<label>Search Provider</label>
|
||||
<select id="adminSearchProvider">
|
||||
<option value="duckduckgo">DuckDuckGo (no API key)</option>
|
||||
<option value="searxng">SearXNG (self-hosted)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="searxngConfigFields" style="display:none">
|
||||
<div class="form-group"><label>SearXNG Endpoint</label><input type="text" id="adminSearchEndpoint" placeholder="https://search.internal.example.com"></div>
|
||||
<div class="form-group"><label>API Key (optional)</label><input type="text" id="adminSearchApiKey" placeholder="Leave blank if not required"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max Results per Search</label>
|
||||
<select id="adminSearchMaxResults">
|
||||
<option value="3">3</option>
|
||||
<option value="5" selected>5</option>
|
||||
<option value="8">8</option>
|
||||
<option value="10">10</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="section-hint">Web search and URL fetch tools allow AI to look up current information. DuckDuckGo works out of the box. SearXNG is ideal for airgapped deployments.</p>
|
||||
</section>
|
||||
<section class="admin-section">
|
||||
<h4>🔐 Encryption</h4>
|
||||
<div id="adminVaultStatus"><span class="empty-hint">Loading…</span></div>
|
||||
@@ -869,6 +899,7 @@
|
||||
<script src="js/tokens.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/notes.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/attachments.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/tools-toggle.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
|
||||
|
||||
@@ -168,11 +168,12 @@ const API = {
|
||||
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
|
||||
},
|
||||
|
||||
async streamRegenerate(channelId, messageId, signal, model, presetId, apiConfigId) {
|
||||
async streamRegenerate(channelId, messageId, signal, model, presetId, apiConfigId, disabledTools) {
|
||||
const body = {};
|
||||
if (model) body.model = model;
|
||||
if (presetId) body.preset_id = presetId;
|
||||
if (apiConfigId) body.provider_config_id = apiConfigId;
|
||||
if (disabledTools?.length) body.disabled_tools = disabledTools;
|
||||
|
||||
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
|
||||
method: 'POST',
|
||||
@@ -226,7 +227,7 @@ const API = {
|
||||
|
||||
// ── Completions ──────────────────────────
|
||||
|
||||
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId, attachmentIds) {
|
||||
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId, attachmentIds, disabledTools) {
|
||||
const body = { channel_id: channelId, content, stream: true };
|
||||
if (presetId) {
|
||||
body.preset_id = presetId;
|
||||
@@ -239,6 +240,7 @@ const API = {
|
||||
// Only send max_tokens if user explicitly set it (non-zero = override)
|
||||
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
|
||||
if (attachmentIds?.length) body.attachment_ids = attachmentIds;
|
||||
if (disabledTools?.length) body.disabled_tools = disabledTools;
|
||||
|
||||
let resp = await fetch(BASE + '/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
@@ -278,6 +280,10 @@ const API = {
|
||||
return resp;
|
||||
},
|
||||
|
||||
// ── Tools ────────────────────────────────
|
||||
|
||||
getTools() { return this._get('/api/v1/tools'); },
|
||||
|
||||
// ── Models ───────────────────────────────
|
||||
|
||||
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
|
||||
|
||||
@@ -187,6 +187,7 @@ async function startApp() {
|
||||
|
||||
await initBanners();
|
||||
initAttachments();
|
||||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.init();
|
||||
UI.renderChatList();
|
||||
UI.updateModelSelector();
|
||||
UI.updateUser();
|
||||
|
||||
@@ -294,7 +294,8 @@ async function sendMessage() {
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId, attachmentIds);
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId, attachmentIds,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []);
|
||||
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||
|
||||
// Reload active path from server to get proper IDs and sibling metadata
|
||||
@@ -391,7 +392,8 @@ async function regenerateMessage(messageId) {
|
||||
try {
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, messageId, App.abortController.signal,
|
||||
model, presetId, modelInfo?.configId
|
||||
model, presetId, modelInfo?.configId,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
||||
);
|
||||
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||
|
||||
@@ -475,7 +477,8 @@ async function submitEdit(messageId, newContent) {
|
||||
// message that streamCompletion would create.
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, editResp.id, App.abortController.signal,
|
||||
model, presetId, modelInfo?.configId
|
||||
model, presetId, modelInfo?.configId,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
||||
);
|
||||
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||
|
||||
|
||||
@@ -195,6 +195,15 @@ async function handleSaveAdminSettings() {
|
||||
};
|
||||
await API.adminUpdateSetting('banner', { value: banner });
|
||||
|
||||
// Web Search config → global_settings (JSON value)
|
||||
const searchConfig = {
|
||||
provider: document.getElementById('adminSearchProvider').value,
|
||||
endpoint: document.getElementById('adminSearchEndpoint').value.trim(),
|
||||
api_key: document.getElementById('adminSearchApiKey').value.trim(),
|
||||
max_results: parseInt(document.getElementById('adminSearchMaxResults').value) || 5,
|
||||
};
|
||||
await API.adminUpdateSetting('search_config', { value: searchConfig });
|
||||
|
||||
UI.toast('Settings saved', 'success');
|
||||
// Live-apply: refresh policies and dependent UI
|
||||
await initBanners();
|
||||
@@ -582,6 +591,11 @@ function _initSettingsListeners() {
|
||||
document.getElementById('adminBannerBgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerBg').value = e.target.value; UI.updateBannerPreview(); }});
|
||||
document.getElementById('adminBannerFgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerFg').value = e.target.value; UI.updateBannerPreview(); }});
|
||||
|
||||
// Admin — search provider controls
|
||||
document.getElementById('adminSearchProvider')?.addEventListener('change', (e) => {
|
||||
document.getElementById('searxngConfigFields').style.display = e.target.value === 'searxng' ? '' : 'none';
|
||||
});
|
||||
|
||||
// Admin — audit log
|
||||
document.getElementById('auditRefreshBtn')?.addEventListener('click', () => UI.loadAuditLog(1));
|
||||
document.getElementById('auditFilterAction')?.addEventListener('change', () => UI.loadAuditLog(1));
|
||||
|
||||
222
src/js/tools-toggle.js
Normal file
222
src/js/tools-toggle.js
Normal file
@@ -0,0 +1,222 @@
|
||||
// ── tools-toggle.js ─────────────────────────
|
||||
// Tools toggle popup: category-based enable/disable of server-side tools.
|
||||
// Categories expand to show individual tool toggles.
|
||||
// State persisted in localStorage, sent as disabled_tools[] in completion requests.
|
||||
|
||||
'use strict';
|
||||
|
||||
const ToolsToggle = (() => {
|
||||
const STORAGE_KEY = 'cs-disabled-tools';
|
||||
|
||||
// Category display config
|
||||
const CATEGORY_META = {
|
||||
search: { label: 'Web Search', icon: '🔍' },
|
||||
notes: { label: 'Notes', icon: '📝' },
|
||||
utilities: { label: 'Utilities', icon: '🧮' },
|
||||
browser: { label: 'Extensions', icon: '🧩' },
|
||||
};
|
||||
|
||||
// Cached tool manifest from GET /api/v1/tools
|
||||
let _tools = []; // [{name, display_name, description, category}]
|
||||
let _categories = {}; // {category: [{name, displayName}]}
|
||||
let _disabled = new Set();
|
||||
let _expanded = new Set(); // which categories are expanded
|
||||
|
||||
// ── Public API ──────────────────────────
|
||||
|
||||
/** Initialize: load disabled set from storage, fetch tool manifest. */
|
||||
async function init() {
|
||||
_loadDisabled();
|
||||
try {
|
||||
const resp = await API.getTools();
|
||||
_tools = resp.data || [];
|
||||
_buildCategories();
|
||||
_render();
|
||||
_show();
|
||||
} catch (e) {
|
||||
console.warn('Tools toggle: failed to load tools', e);
|
||||
}
|
||||
_wireEvents();
|
||||
}
|
||||
|
||||
/** Returns array of disabled tool names for the completion request. */
|
||||
function getDisabledTools() {
|
||||
return [..._disabled];
|
||||
}
|
||||
|
||||
/** Refresh tool list (e.g. after browser extension connects). */
|
||||
async function refresh() {
|
||||
try {
|
||||
const resp = await API.getTools();
|
||||
_tools = resp.data || [];
|
||||
_buildCategories();
|
||||
_render();
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Internal ────────────────────────────
|
||||
|
||||
function _loadDisabled() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
_disabled = raw ? new Set(JSON.parse(raw)) : new Set();
|
||||
} catch { _disabled = new Set(); }
|
||||
}
|
||||
|
||||
function _saveDisabled() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([..._disabled]));
|
||||
_updateBtnState();
|
||||
}
|
||||
|
||||
function _buildCategories() {
|
||||
_categories = {};
|
||||
for (const t of _tools) {
|
||||
const cat = t.category || 'other';
|
||||
if (!_categories[cat]) _categories[cat] = [];
|
||||
_categories[cat].push({
|
||||
name: t.name,
|
||||
displayName: t.display_name || t.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns 'all' | 'none' | 'partial' for a category's enabled state. */
|
||||
function _categoryState(cat) {
|
||||
const tools = _categories[cat] || [];
|
||||
if (tools.length === 0) return 'none';
|
||||
const enabledCount = tools.filter(t => !_disabled.has(t.name)).length;
|
||||
if (enabledCount === tools.length) return 'all';
|
||||
if (enabledCount === 0) return 'none';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
function _toggleCategory(cat) {
|
||||
const tools = _categories[cat] || [];
|
||||
const state = _categoryState(cat);
|
||||
if (state === 'all') {
|
||||
// All on → all off
|
||||
tools.forEach(t => _disabled.add(t.name));
|
||||
} else {
|
||||
// Partial or none → all on
|
||||
tools.forEach(t => _disabled.delete(t.name));
|
||||
}
|
||||
_saveDisabled();
|
||||
_render();
|
||||
}
|
||||
|
||||
function _toggleTool(name) {
|
||||
if (_disabled.has(name)) {
|
||||
_disabled.delete(name);
|
||||
} else {
|
||||
_disabled.add(name);
|
||||
}
|
||||
_saveDisabled();
|
||||
_render();
|
||||
}
|
||||
|
||||
function _toggleExpand(cat) {
|
||||
if (_expanded.has(cat)) {
|
||||
_expanded.delete(cat);
|
||||
} else {
|
||||
_expanded.add(cat);
|
||||
}
|
||||
_render();
|
||||
}
|
||||
|
||||
function _show() {
|
||||
const wrap = document.querySelector('.tools-toggle-wrap');
|
||||
if (wrap && _tools.length > 0) {
|
||||
wrap.style.display = '';
|
||||
_updateBtnState();
|
||||
}
|
||||
}
|
||||
|
||||
function _updateBtnState() {
|
||||
const btn = document.getElementById('toolsToggleBtn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('has-disabled', _disabled.size > 0);
|
||||
const count = _disabled.size;
|
||||
btn.title = count > 0 ? `Tools (${count} disabled)` : 'Tools';
|
||||
}
|
||||
|
||||
function _render() {
|
||||
const popup = document.getElementById('toolsPopup');
|
||||
if (!popup) return;
|
||||
|
||||
const cats = Object.keys(_categories);
|
||||
const order = Object.keys(CATEGORY_META);
|
||||
cats.sort((a, b) => {
|
||||
const ai = order.indexOf(a), bi = order.indexOf(b);
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
||||
});
|
||||
|
||||
let html = '';
|
||||
for (const cat of cats) {
|
||||
const meta = CATEGORY_META[cat] || { label: cat, icon: '🔧' };
|
||||
const state = _categoryState(cat);
|
||||
const tools = _categories[cat];
|
||||
const expanded = _expanded.has(cat);
|
||||
const stateClass = state === 'all' ? 'enabled' : state === 'partial' ? 'partial' : '';
|
||||
const chevron = tools.length > 1
|
||||
? `<span class="tools-popup-expand ${expanded ? 'open' : ''}" data-expand="${cat}">›</span>`
|
||||
: '';
|
||||
|
||||
html += `<div class="tools-popup-category ${stateClass}" data-category="${cat}">
|
||||
<span class="tools-popup-check"></span>
|
||||
<span class="tools-popup-label">${meta.icon} ${meta.label}</span>
|
||||
${chevron}
|
||||
</div>`;
|
||||
|
||||
// Expanded children
|
||||
if (expanded && tools.length > 1) {
|
||||
for (const t of tools) {
|
||||
const on = !_disabled.has(t.name);
|
||||
html += `<div class="tools-popup-tool ${on ? 'enabled' : ''}" data-tool="${t.name}">
|
||||
<span class="tools-popup-check"></span>
|
||||
<span class="tools-popup-label">${t.displayName}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
popup.innerHTML = html;
|
||||
}
|
||||
|
||||
function _wireEvents() {
|
||||
const btn = document.getElementById('toolsToggleBtn');
|
||||
const popup = document.getElementById('toolsPopup');
|
||||
if (!btn || !popup) return;
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
popup.classList.toggle('open');
|
||||
});
|
||||
|
||||
popup.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Expand/collapse chevron
|
||||
const expander = e.target.closest('.tools-popup-expand');
|
||||
if (expander) {
|
||||
_toggleExpand(expander.dataset.expand);
|
||||
return;
|
||||
}
|
||||
|
||||
// Individual tool toggle
|
||||
const toolRow = e.target.closest('.tools-popup-tool');
|
||||
if (toolRow) {
|
||||
_toggleTool(toolRow.dataset.tool);
|
||||
return;
|
||||
}
|
||||
|
||||
// Category toggle
|
||||
const catRow = e.target.closest('.tools-popup-category');
|
||||
if (catRow) _toggleCategory(catRow.dataset.category);
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', () => popup.classList.remove('open'));
|
||||
}
|
||||
|
||||
return { init, getDisabledTools, refresh };
|
||||
})();
|
||||
@@ -780,6 +780,16 @@ Object.assign(UI, {
|
||||
|
||||
// Vault / Encryption status
|
||||
const vaultEl = document.getElementById('adminVaultStatus');
|
||||
|
||||
// Web Search config (global_settings)
|
||||
const searchCfg = getSetting('search_config', {}) || {};
|
||||
document.getElementById('adminSearchProvider').value = searchCfg.provider || 'duckduckgo';
|
||||
document.getElementById('adminSearchEndpoint').value = searchCfg.endpoint || '';
|
||||
document.getElementById('adminSearchApiKey').value = searchCfg.api_key || '';
|
||||
document.getElementById('adminSearchMaxResults').value = String(searchCfg.max_results || 5);
|
||||
document.getElementById('searxngConfigFields').style.display =
|
||||
(searchCfg.provider === 'searxng') ? '' : 'none';
|
||||
|
||||
if (vaultEl) {
|
||||
try {
|
||||
const vault = await API.adminGetVaultStatus();
|
||||
|
||||
Reference in New Issue
Block a user