feat: collapse tool results and add headless Chrome fetcher
Backend: - Add unified URL fetcher with fallback chain: curl → wget → native Go → headless Chrome - Implement JS-rendered page detection for sites like docs.rs - Add chromedp dependency for headless browser support - Log fetch method on server startup Frontend: - Store tool results in structured ToolCall.result field instead of message content - Show tool results collapsed by default in ToolCallDisplay - Add expandable results section with truncation for large outputs - Add Message.hidden flag for internal messages (tool context) - Separate visibleMessages (UI) from allMessages (API) to fix infinite loop - Fix tool result messages not being sent to model 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,10 @@ func main() {
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// Initialize fetcher and log the method being used
|
||||
fetcher := api.GetFetcher()
|
||||
log.Printf("URL fetcher method: %s (headless Chrome: %v)", fetcher.Method(), fetcher.HasChrome())
|
||||
|
||||
// Graceful shutdown handling
|
||||
go func() {
|
||||
log.Printf("Server starting on port %s", *port)
|
||||
|
||||
@@ -3,6 +3,7 @@ module ollama-webui-backend
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/chromedp/chromedp v0.11.2
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -12,6 +13,8 @@ require (
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -20,12 +23,17 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
@@ -37,7 +45,7 @@ require (
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -2,6 +2,12 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb h1:noKVm2SsG4v0Yd0lHNtFYc9EUxIVvrr4kJ6hM8wvIYU=
|
||||
github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
|
||||
github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0=
|
||||
github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
@@ -28,6 +34,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
@@ -39,6 +51,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@@ -49,8 +63,12 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -60,6 +78,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -95,8 +115,8 @@ golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
|
||||
644
backend/internal/api/fetcher.go
Normal file
644
backend/internal/api/fetcher.go
Normal file
@@ -0,0 +1,644 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
// FetchMethod represents the method used to fetch URLs
|
||||
type FetchMethod string
|
||||
|
||||
const (
|
||||
FetchMethodCurl FetchMethod = "curl"
|
||||
FetchMethodWget FetchMethod = "wget"
|
||||
FetchMethodChrome FetchMethod = "chrome"
|
||||
FetchMethodNative FetchMethod = "native"
|
||||
)
|
||||
|
||||
// FetchResult contains the result of a URL fetch
|
||||
type FetchResult struct {
|
||||
Content string
|
||||
ContentType string
|
||||
FinalURL string
|
||||
StatusCode int
|
||||
Method FetchMethod
|
||||
}
|
||||
|
||||
// FetchOptions configures the fetch behavior
|
||||
type FetchOptions struct {
|
||||
MaxLength int
|
||||
Timeout time.Duration
|
||||
UserAgent string
|
||||
Headers map[string]string
|
||||
FollowRedirects bool
|
||||
// ForceHeadless forces using headless browser even if curl succeeds
|
||||
ForceHeadless bool
|
||||
// WaitForSelector waits for a specific CSS selector before capturing content
|
||||
WaitForSelector string
|
||||
// WaitTime is additional time to wait for JS to render (default 2s for headless)
|
||||
WaitTime time.Duration
|
||||
}
|
||||
|
||||
// DefaultFetchOptions returns sensible defaults
|
||||
func DefaultFetchOptions() FetchOptions {
|
||||
return FetchOptions{
|
||||
MaxLength: 500000, // 500KB
|
||||
Timeout: 30 * time.Second,
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
Headers: make(map[string]string),
|
||||
FollowRedirects: true,
|
||||
WaitTime: 2 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Fetcher provides URL fetching with multiple backend support
|
||||
type Fetcher struct {
|
||||
curlPath string
|
||||
wgetPath string
|
||||
chromePath string
|
||||
httpClient *http.Client
|
||||
method FetchMethod
|
||||
hasChrome bool
|
||||
mu sync.RWMutex
|
||||
|
||||
// chromedp allocator context (reused for efficiency)
|
||||
allocCtx context.Context
|
||||
allocCancel context.CancelFunc
|
||||
}
|
||||
|
||||
var (
|
||||
globalFetcher *Fetcher
|
||||
fetcherOnce sync.Once
|
||||
)
|
||||
|
||||
// GetFetcher returns the singleton Fetcher instance
|
||||
func GetFetcher() *Fetcher {
|
||||
fetcherOnce.Do(func() {
|
||||
globalFetcher = NewFetcher()
|
||||
})
|
||||
return globalFetcher
|
||||
}
|
||||
|
||||
// NewFetcher creates a new Fetcher, detecting available tools
|
||||
func NewFetcher() *Fetcher {
|
||||
f := &Fetcher{}
|
||||
f.detectTools()
|
||||
f.initHTTPClient()
|
||||
f.initChromeDp()
|
||||
return f
|
||||
}
|
||||
|
||||
// detectTools checks which external tools are available
|
||||
func (f *Fetcher) detectTools() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
// Check for curl
|
||||
if path, err := exec.LookPath("curl"); err == nil {
|
||||
f.curlPath = path
|
||||
f.method = FetchMethodCurl
|
||||
}
|
||||
|
||||
// Check for wget
|
||||
if path, err := exec.LookPath("wget"); err == nil {
|
||||
f.wgetPath = path
|
||||
if f.method == "" {
|
||||
f.method = FetchMethodWget
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Chrome/Chromium (for headless browser support)
|
||||
chromePaths := []string{
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/snap/bin/chromium",
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
}
|
||||
|
||||
for _, p := range chromePaths {
|
||||
if path, err := exec.LookPath(p); err == nil {
|
||||
f.chromePath = path
|
||||
f.hasChrome = true
|
||||
log.Printf("[Fetcher] Found Chrome at: %s", path)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to native if nothing else available
|
||||
if f.method == "" {
|
||||
f.method = FetchMethodNative
|
||||
}
|
||||
}
|
||||
|
||||
// initHTTPClient sets up the native Go HTTP client with cookie support
|
||||
func (f *Fetcher) initHTTPClient() {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
|
||||
f.httpClient = &http.Client{
|
||||
Jar: jar,
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// initChromeDp initializes the chromedp allocator if Chrome is available
|
||||
func (f *Fetcher) initChromeDp() {
|
||||
if !f.hasChrome {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a persistent allocator context for reuse
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-dev-shm-usage", true),
|
||||
chromedp.Flag("disable-extensions", true),
|
||||
chromedp.Flag("disable-background-networking", true),
|
||||
chromedp.Flag("disable-sync", true),
|
||||
chromedp.Flag("disable-translate", true),
|
||||
chromedp.Flag("mute-audio", true),
|
||||
chromedp.Flag("hide-scrollbars", true),
|
||||
chromedp.UserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
|
||||
)
|
||||
|
||||
if f.chromePath != "" {
|
||||
opts = append(opts, chromedp.ExecPath(f.chromePath))
|
||||
}
|
||||
|
||||
f.allocCtx, f.allocCancel = chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
log.Printf("[Fetcher] Chrome headless browser initialized")
|
||||
}
|
||||
|
||||
// Close cleans up resources
|
||||
func (f *Fetcher) Close() {
|
||||
if f.allocCancel != nil {
|
||||
f.allocCancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Method returns the current primary fetch method being used
|
||||
func (f *Fetcher) Method() FetchMethod {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
return f.method
|
||||
}
|
||||
|
||||
// HasChrome returns whether headless Chrome is available
|
||||
func (f *Fetcher) HasChrome() bool {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
return f.hasChrome
|
||||
}
|
||||
|
||||
// Fetch fetches a URL using the best available method
|
||||
// For most sites, uses curl/wget. Falls back to headless browser for JS-heavy sites.
|
||||
func (f *Fetcher) Fetch(ctx context.Context, url string, opts FetchOptions) (*FetchResult, error) {
|
||||
// If force headless is set and Chrome is available, use it directly
|
||||
if opts.ForceHeadless && f.hasChrome {
|
||||
return f.fetchWithChrome(ctx, url, opts)
|
||||
}
|
||||
|
||||
// Try fast methods first
|
||||
result, err := f.fetchFast(ctx, url, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if content looks like a JS-rendered page that needs headless browser
|
||||
if f.hasChrome && f.isJSRenderedPage(result.Content) {
|
||||
log.Printf("[Fetcher] Content appears to be JS-rendered, trying headless browser for: %s", url)
|
||||
headlessResult, headlessErr := f.fetchWithChrome(ctx, url, opts)
|
||||
if headlessErr == nil && len(headlessResult.Content) > len(result.Content) {
|
||||
return headlessResult, nil
|
||||
}
|
||||
// If headless failed or got less content, return original
|
||||
if headlessErr != nil {
|
||||
log.Printf("[Fetcher] Headless browser failed: %v, using original content", headlessErr)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fetchFast tries curl, wget, or native HTTP in order
|
||||
func (f *Fetcher) fetchFast(ctx context.Context, url string, opts FetchOptions) (*FetchResult, error) {
|
||||
f.mu.RLock()
|
||||
curlPath := f.curlPath
|
||||
wgetPath := f.wgetPath
|
||||
method := f.method
|
||||
f.mu.RUnlock()
|
||||
|
||||
switch method {
|
||||
case FetchMethodCurl:
|
||||
return f.fetchWithCurl(ctx, url, curlPath, opts)
|
||||
case FetchMethodWget:
|
||||
return f.fetchWithWget(ctx, url, wgetPath, opts)
|
||||
default:
|
||||
return f.fetchNative(ctx, url, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// isJSRenderedPage checks if the content appears to be a JS-rendered page
|
||||
// that hasn't actually rendered its content yet
|
||||
func (f *Fetcher) isJSRenderedPage(content string) bool {
|
||||
// Too short content often indicates JS rendering needed
|
||||
if len(strings.TrimSpace(content)) < 500 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Common patterns indicating JS-only rendering
|
||||
jsPatterns := []string{
|
||||
`<div id="root"></div>`,
|
||||
`<div id="app"></div>`,
|
||||
`<div id="__next"></div>`,
|
||||
`<div id="__nuxt"></div>`,
|
||||
`noscript`,
|
||||
`"Loading..."`,
|
||||
`"loading..."`,
|
||||
`window.__INITIAL_STATE__`,
|
||||
`window.__NUXT__`,
|
||||
`window.__NEXT_DATA__`,
|
||||
}
|
||||
|
||||
contentLower := strings.ToLower(content)
|
||||
for _, pattern := range jsPatterns {
|
||||
if strings.Contains(contentLower, strings.ToLower(pattern)) {
|
||||
// Found JS pattern, but also check if there's substantial content
|
||||
// Extract text content (very rough)
|
||||
textContent := stripHTMLTags(content)
|
||||
if len(strings.TrimSpace(textContent)) < 1000 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for common documentation sites that need JS
|
||||
jsHeavySites := []string{
|
||||
"docs.rs",
|
||||
"reactjs.org",
|
||||
"vuejs.org",
|
||||
"angular.io",
|
||||
"nextjs.org",
|
||||
"vercel.com",
|
||||
"netlify.com",
|
||||
}
|
||||
|
||||
for _, site := range jsHeavySites {
|
||||
if strings.Contains(content, site) {
|
||||
textContent := stripHTMLTags(content)
|
||||
if len(strings.TrimSpace(textContent)) < 2000 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// stripHTMLTags removes HTML tags from content (rough extraction)
|
||||
func stripHTMLTags(content string) string {
|
||||
// Remove script and style tags with their content
|
||||
scriptRe := regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
|
||||
content = scriptRe.ReplaceAllString(content, "")
|
||||
|
||||
styleRe := regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`)
|
||||
content = styleRe.ReplaceAllString(content, "")
|
||||
|
||||
// Remove all remaining tags
|
||||
tagRe := regexp.MustCompile(`<[^>]*>`)
|
||||
content = tagRe.ReplaceAllString(content, " ")
|
||||
|
||||
// Collapse whitespace
|
||||
spaceRe := regexp.MustCompile(`\s+`)
|
||||
content = spaceRe.ReplaceAllString(content, " ")
|
||||
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
// fetchWithChrome uses headless Chrome to fetch and render the page
|
||||
func (f *Fetcher) fetchWithChrome(ctx context.Context, url string, opts FetchOptions) (*FetchResult, error) {
|
||||
if !f.hasChrome || f.allocCtx == nil {
|
||||
return nil, fmt.Errorf("headless Chrome not available")
|
||||
}
|
||||
|
||||
// Create a timeout context
|
||||
timeout := opts.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
// Create a new browser context from the allocator
|
||||
browserCtx, browserCancel := chromedp.NewContext(f.allocCtx)
|
||||
defer browserCancel()
|
||||
|
||||
var content string
|
||||
var finalURL string
|
||||
|
||||
// Wait time for JS to render
|
||||
waitTime := opts.WaitTime
|
||||
if waitTime == 0 {
|
||||
waitTime = 2 * time.Second
|
||||
}
|
||||
|
||||
// Build the actions
|
||||
actions := []chromedp.Action{
|
||||
chromedp.Navigate(url),
|
||||
}
|
||||
|
||||
// Wait for specific selector if provided
|
||||
if opts.WaitForSelector != "" {
|
||||
actions = append(actions, chromedp.WaitVisible(opts.WaitForSelector, chromedp.ByQuery))
|
||||
} else {
|
||||
// Default: wait for body to be visible and give JS time to render
|
||||
actions = append(actions,
|
||||
chromedp.WaitVisible("body", chromedp.ByQuery),
|
||||
chromedp.Sleep(waitTime),
|
||||
)
|
||||
}
|
||||
|
||||
// Get the final URL and content
|
||||
actions = append(actions,
|
||||
chromedp.Location(&finalURL),
|
||||
chromedp.OuterHTML("html", &content, chromedp.ByQuery),
|
||||
)
|
||||
|
||||
// Execute
|
||||
if err := chromedp.Run(browserCtx, actions...); err != nil {
|
||||
return nil, fmt.Errorf("chromedp failed: %w", err)
|
||||
}
|
||||
|
||||
// Truncate if needed
|
||||
if len(content) > opts.MaxLength {
|
||||
content = content[:opts.MaxLength]
|
||||
}
|
||||
|
||||
return &FetchResult{
|
||||
Content: content,
|
||||
ContentType: "text/html",
|
||||
FinalURL: finalURL,
|
||||
StatusCode: 200,
|
||||
Method: FetchMethodChrome,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchWithCurl uses curl to fetch the URL
|
||||
func (f *Fetcher) fetchWithCurl(ctx context.Context, url string, curlPath string, opts FetchOptions) (*FetchResult, error) {
|
||||
args := []string{
|
||||
"-sS", // Silent but show errors
|
||||
"-L", // Follow redirects
|
||||
"--max-time", fmt.Sprintf("%d", int(opts.Timeout.Seconds())),
|
||||
"--max-filesize", fmt.Sprintf("%d", opts.MaxLength),
|
||||
"-A", opts.UserAgent, // User agent
|
||||
"-w", "\n---CURL_INFO---\n%{content_type}\n%{url_effective}\n%{http_code}", // Output metadata
|
||||
"--compressed", // Accept compressed responses
|
||||
}
|
||||
|
||||
// Add custom headers
|
||||
for key, value := range opts.Headers {
|
||||
args = append(args, "-H", fmt.Sprintf("%s: %s", key, value))
|
||||
}
|
||||
|
||||
// Add common headers for better compatibility
|
||||
args = append(args,
|
||||
"-H", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"-H", "Accept-Language: en-US,en;q=0.5",
|
||||
"-H", "DNT: 1",
|
||||
"-H", "Connection: keep-alive",
|
||||
"-H", "Upgrade-Insecure-Requests: 1",
|
||||
)
|
||||
|
||||
args = append(args, url)
|
||||
|
||||
cmd := exec.CommandContext(ctx, curlPath, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Check if it's a context cancellation
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, fmt.Errorf("curl failed: %s - %s", err.Error(), stderr.String())
|
||||
}
|
||||
|
||||
output := stdout.String()
|
||||
|
||||
// Parse the output - content and metadata are separated by ---CURL_INFO---
|
||||
parts := strings.Split(output, "\n---CURL_INFO---\n")
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("unexpected curl output format")
|
||||
}
|
||||
|
||||
content := parts[0]
|
||||
metaLines := strings.Split(strings.TrimSpace(parts[1]), "\n")
|
||||
|
||||
if len(metaLines) < 3 {
|
||||
return nil, fmt.Errorf("incomplete curl metadata")
|
||||
}
|
||||
|
||||
contentType := metaLines[0]
|
||||
finalURL := metaLines[1]
|
||||
statusCode := 200
|
||||
fmt.Sscanf(metaLines[2], "%d", &statusCode)
|
||||
|
||||
// Truncate content if needed
|
||||
if len(content) > opts.MaxLength {
|
||||
content = content[:opts.MaxLength]
|
||||
}
|
||||
|
||||
return &FetchResult{
|
||||
Content: content,
|
||||
ContentType: contentType,
|
||||
FinalURL: finalURL,
|
||||
StatusCode: statusCode,
|
||||
Method: FetchMethodCurl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchWithWget uses wget to fetch the URL
|
||||
func (f *Fetcher) fetchWithWget(ctx context.Context, url string, wgetPath string, opts FetchOptions) (*FetchResult, error) {
|
||||
args := []string{
|
||||
"-q", // Quiet
|
||||
"-O", "-", // Output to stdout
|
||||
"--timeout", fmt.Sprintf("%d", int(opts.Timeout.Seconds())),
|
||||
"--user-agent", opts.UserAgent,
|
||||
"--max-redirect", "10", // Follow up to 10 redirects
|
||||
"--header", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"--header", "Accept-Language: en-US,en;q=0.5",
|
||||
}
|
||||
|
||||
// Add custom headers
|
||||
for key, value := range opts.Headers {
|
||||
args = append(args, "--header", fmt.Sprintf("%s: %s", key, value))
|
||||
}
|
||||
|
||||
args = append(args, url)
|
||||
|
||||
cmd := exec.CommandContext(ctx, wgetPath, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, fmt.Errorf("wget failed: %s - %s", err.Error(), stderr.String())
|
||||
}
|
||||
|
||||
content := stdout.String()
|
||||
|
||||
// Truncate content if needed
|
||||
if len(content) > opts.MaxLength {
|
||||
content = content[:opts.MaxLength]
|
||||
}
|
||||
|
||||
// wget doesn't easily provide metadata, so we use defaults
|
||||
return &FetchResult{
|
||||
Content: content,
|
||||
ContentType: "text/html", // Assume HTML (wget doesn't easily give us this)
|
||||
FinalURL: url, // wget doesn't easily give us the final URL
|
||||
StatusCode: 200,
|
||||
Method: FetchMethodWget,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchNative uses Go's native http.Client with enhanced capabilities
|
||||
func (f *Fetcher) fetchNative(ctx context.Context, url string, opts FetchOptions) (*FetchResult, error) {
|
||||
// Create request with context
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("User-Agent", opts.UserAgent)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
|
||||
req.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
req.Header.Set("DNT", "1")
|
||||
req.Header.Set("Connection", "keep-alive")
|
||||
req.Header.Set("Upgrade-Insecure-Requests", "1")
|
||||
|
||||
// Add custom headers
|
||||
for key, value := range opts.Headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
// Create a client with custom timeout
|
||||
client := &http.Client{
|
||||
Jar: f.httpClient.Jar,
|
||||
Timeout: opts.Timeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if !opts.FollowRedirects {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body with limit
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(opts.MaxLength)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return &FetchResult{
|
||||
Content: string(body),
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
FinalURL: resp.Request.URL.String(),
|
||||
StatusCode: resp.StatusCode,
|
||||
Method: FetchMethodNative,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FetchWithHeadless explicitly uses headless browser (for API use)
|
||||
func (f *Fetcher) FetchWithHeadless(ctx context.Context, url string, opts FetchOptions) (*FetchResult, error) {
|
||||
if !f.hasChrome {
|
||||
return nil, fmt.Errorf("headless Chrome not available - Chrome/Chromium not found")
|
||||
}
|
||||
return f.fetchWithChrome(ctx, url, opts)
|
||||
}
|
||||
|
||||
// TryFetchWithFallback attempts to fetch using all available methods
|
||||
func (f *Fetcher) TryFetchWithFallback(ctx context.Context, url string, opts FetchOptions) (*FetchResult, error) {
|
||||
f.mu.RLock()
|
||||
curlPath := f.curlPath
|
||||
wgetPath := f.wgetPath
|
||||
hasChrome := f.hasChrome
|
||||
f.mu.RUnlock()
|
||||
|
||||
var lastErr error
|
||||
|
||||
// Try curl first if available
|
||||
if curlPath != "" {
|
||||
result, err := f.fetchWithCurl(ctx, url, curlPath, opts)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
lastErr = fmt.Errorf("curl: %w", err)
|
||||
}
|
||||
|
||||
// Try wget if available
|
||||
if wgetPath != "" {
|
||||
result, err := f.fetchWithWget(ctx, url, wgetPath, opts)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
lastErr = fmt.Errorf("wget: %w", err)
|
||||
}
|
||||
|
||||
// Try native HTTP
|
||||
result, err := f.fetchNative(ctx, url, opts)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
lastErr = fmt.Errorf("native: %w", err)
|
||||
|
||||
// Last resort: try headless Chrome
|
||||
if hasChrome {
|
||||
result, err := f.fetchWithChrome(ctx, url, opts)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
lastErr = fmt.Errorf("chrome: %w", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all fetch methods failed: %v", lastErr)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -17,7 +16,10 @@ type URLFetchRequest struct {
|
||||
|
||||
// URLFetchProxyHandler returns a handler that fetches URLs for the frontend
|
||||
// This bypasses CORS restrictions for the fetch_url tool
|
||||
// Uses curl/wget when available for better compatibility, falls back to native Go
|
||||
func URLFetchProxyHandler() gin.HandlerFunc {
|
||||
fetcher := GetFetcher()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
var req URLFetchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -38,55 +40,50 @@ func URLFetchProxyHandler() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Create HTTP client with timeout
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
// Set up fetch options
|
||||
opts := DefaultFetchOptions()
|
||||
opts.Timeout = 30 * time.Second
|
||||
|
||||
// Set max length (default 500KB)
|
||||
if req.MaxLength > 0 && req.MaxLength <= 500000 {
|
||||
opts.MaxLength = req.MaxLength
|
||||
}
|
||||
|
||||
// Create request
|
||||
httpReq, err := http.NewRequestWithContext(c.Request.Context(), "GET", req.URL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set user agent
|
||||
httpReq.Header.Set("User-Agent", "OllamaWebUI/1.0 (URL Fetch Proxy)")
|
||||
httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
|
||||
// Execute request
|
||||
resp, err := client.Do(httpReq)
|
||||
// Fetch the URL
|
||||
result, err := fetcher.Fetch(c.Request.Context(), req.URL, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch URL: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status
|
||||
if resp.StatusCode >= 400 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "HTTP " + resp.Status})
|
||||
return
|
||||
}
|
||||
|
||||
// Set max length (default 500KB)
|
||||
maxLen := req.MaxLength
|
||||
if maxLen <= 0 || maxLen > 500000 {
|
||||
maxLen = 500000
|
||||
}
|
||||
|
||||
// Read response body with limit
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxLen)))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read response: " + err.Error()})
|
||||
if result.StatusCode >= 400 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": "HTTP " + http.StatusText(result.StatusCode),
|
||||
"status": result.StatusCode,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return the content
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"content": string(body),
|
||||
"contentType": resp.Header.Get("Content-Type"),
|
||||
"url": resp.Request.URL.String(), // Final URL after redirects
|
||||
"status": resp.StatusCode,
|
||||
"content": result.Content,
|
||||
"contentType": result.ContentType,
|
||||
"url": result.FinalURL,
|
||||
"status": result.StatusCode,
|
||||
"fetchMethod": string(result.Method),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetFetchMethodHandler returns a handler that reports the current fetch method
|
||||
func GetFetchMethodHandler() gin.HandlerFunc {
|
||||
fetcher := GetFetcher()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"method": string(fetcher.Method()),
|
||||
"hasChrome": fetcher.HasChrome(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,9 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string) {
|
||||
}
|
||||
|
||||
// URL fetch proxy (for tools that need to fetch external URLs)
|
||||
// Uses curl/wget when available, falls back to native Go HTTP client
|
||||
v1.POST("/proxy/fetch", URLFetchProxyHandler())
|
||||
v1.GET("/proxy/fetch-method", GetFetchMethodHandler())
|
||||
|
||||
// Web search proxy (for web_search tool)
|
||||
v1.POST("/proxy/search", WebSearchProxyHandler())
|
||||
|
||||
@@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -26,7 +25,10 @@ type SearchResult struct {
|
||||
}
|
||||
|
||||
// WebSearchProxyHandler returns a handler that performs web searches via DuckDuckGo
|
||||
// Uses curl/wget when available for better compatibility
|
||||
func WebSearchProxyHandler() gin.HandlerFunc {
|
||||
fetcher := GetFetcher()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
var req SearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -46,51 +48,32 @@ func WebSearchProxyHandler() gin.HandlerFunc {
|
||||
// Build DuckDuckGo HTML search URL
|
||||
searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(req.Query))
|
||||
|
||||
// Create HTTP client with timeout
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
// Set up fetch options with browser-like headers
|
||||
opts := DefaultFetchOptions()
|
||||
opts.Timeout = 20 * time.Second
|
||||
opts.MaxLength = 500000 // 500KB is plenty for search results
|
||||
|
||||
// Create request
|
||||
httpReq, err := http.NewRequestWithContext(c.Request.Context(), "GET", searchURL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set headers to mimic a browser
|
||||
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
httpReq.Header.Set("Accept-Language", "en-US,en;q=0.5")
|
||||
|
||||
// Execute request
|
||||
resp, err := client.Do(httpReq)
|
||||
// Fetch search results
|
||||
result, err := fetcher.Fetch(c.Request.Context(), searchURL, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to perform search: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status
|
||||
if resp.StatusCode >= 400 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "search failed: HTTP " + resp.Status})
|
||||
return
|
||||
}
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 500000)) // 500KB limit
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read response: " + err.Error()})
|
||||
if result.StatusCode >= 400 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "search failed: HTTP " + http.StatusText(result.StatusCode)})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse results from HTML
|
||||
results := parseDuckDuckGoResults(string(body), maxResults)
|
||||
results := parseDuckDuckGoResults(result.Content, maxResults)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"query": req.Query,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
"query": req.Query,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
"fetchMethod": string(result.Method),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,9 +161,10 @@
|
||||
|
||||
/**
|
||||
* Convert chat state messages to Ollama API format
|
||||
* Uses allMessages to include hidden tool result messages
|
||||
*/
|
||||
function getMessagesForApi(): OllamaMessage[] {
|
||||
return chatState.visibleMessages.map((node) => ({
|
||||
return chatState.allMessages.map((node) => ({
|
||||
role: node.message.role as OllamaMessage['role'],
|
||||
content: node.message.content,
|
||||
images: node.message.images
|
||||
@@ -439,43 +440,41 @@
|
||||
isExecutingTools = true;
|
||||
|
||||
try {
|
||||
// Convert tool calls to executor format
|
||||
const convertedCalls = convertToolCalls(toolCalls);
|
||||
// Convert tool calls to executor format with stable IDs
|
||||
const callIds = toolCalls.map(() => crypto.randomUUID());
|
||||
const convertedCalls = toolCalls.map((tc, i) => ({
|
||||
id: callIds[i],
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments
|
||||
}));
|
||||
|
||||
// Execute all tools (including custom tools)
|
||||
const results = await runToolCalls(convertedCalls, undefined, toolsState.customTools);
|
||||
|
||||
// Format results for chat
|
||||
// Format results for model context (still needed for LLM to respond)
|
||||
const toolResultContent = formatToolResultsForChat(results);
|
||||
|
||||
// Add the assistant's tool call response to chat (with info about what was called)
|
||||
const toolCallInfo = toolCalls
|
||||
.map(tc => `Called tool: ${tc.function.name}(${JSON.stringify(tc.function.arguments)})`)
|
||||
.join('\n');
|
||||
|
||||
// Update the assistant message with tool call info and structured data
|
||||
// Update the assistant message with structured tool call data (including results)
|
||||
const assistantNode = chatState.messageTree.get(assistantMessageId);
|
||||
if (assistantNode) {
|
||||
// Preserve any thinking content that was already streamed
|
||||
const existingContent = assistantNode.message.content || '';
|
||||
const newContent = toolCallInfo + '\n\n' + toolResultContent;
|
||||
// Store structured tool call data WITH results for display
|
||||
// Results are shown collapsed in ToolCallDisplay - NOT as raw message content
|
||||
assistantNode.message.toolCalls = toolCalls.map((tc, i) => {
|
||||
const result = results[i];
|
||||
return {
|
||||
id: callIds[i],
|
||||
name: tc.function.name,
|
||||
arguments: JSON.stringify(tc.function.arguments),
|
||||
result: result.success ? (typeof result.result === 'object' ? JSON.stringify(result.result) : String(result.result)) : undefined,
|
||||
error: result.success ? undefined : result.error
|
||||
};
|
||||
});
|
||||
|
||||
// If there's existing content (like thinking), append tool info after it
|
||||
if (existingContent.trim()) {
|
||||
assistantNode.message.content = existingContent + '\n\n' + newContent;
|
||||
} else {
|
||||
assistantNode.message.content = newContent;
|
||||
}
|
||||
|
||||
// Store structured tool call data for display
|
||||
assistantNode.message.toolCalls = toolCalls.map(tc => ({
|
||||
id: crypto.randomUUID(),
|
||||
name: tc.function.name,
|
||||
arguments: JSON.stringify(tc.function.arguments)
|
||||
}));
|
||||
// DON'T add tool results to message content - that's what floods the UI
|
||||
// The results are stored in toolCalls and displayed by ToolCallDisplay
|
||||
}
|
||||
|
||||
// Persist the assistant message with tool info (including any thinking content)
|
||||
// Persist the assistant message (without flooding text content)
|
||||
if (conversationId && assistantNode) {
|
||||
const parentOfAssistant = assistantNode.parentId;
|
||||
await addStoredMessage(
|
||||
@@ -486,11 +485,11 @@
|
||||
);
|
||||
}
|
||||
|
||||
// Now stream a follow-up response that uses the tool results
|
||||
// Add tool results as a system/tool message to context
|
||||
// Add tool results as a hidden message (for model context, not displayed in UI)
|
||||
const toolMessageId = chatState.addMessage({
|
||||
role: 'user', // Ollama expects tool results in a user-like message
|
||||
content: `Tool execution results:\n${toolResultContent}\n\nBased on these results, either provide a helpful response OR call another tool if you need more information.`
|
||||
role: 'user',
|
||||
content: `Tool execution results:\n${toolResultContent}\n\nBased on these results, either provide a helpful response OR call another tool if you need more information.`,
|
||||
hidden: true
|
||||
});
|
||||
|
||||
if (conversationId) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* ToolCallDisplay - Beautiful tool call visualization
|
||||
* Shows tool name, formatted arguments, and status
|
||||
* Shows tool name, arguments, and results (collapsed by default)
|
||||
*/
|
||||
|
||||
import type { ToolCall } from '$lib/types';
|
||||
@@ -90,8 +90,75 @@
|
||||
return labels[key] || key;
|
||||
}
|
||||
|
||||
// Collapsed state per tool
|
||||
/**
|
||||
* Parse result content (could be JSON or plain text)
|
||||
*/
|
||||
function parseResult(result: string | undefined): { type: string; summary: string; full: string } {
|
||||
if (!result) return { type: 'empty', summary: 'No result', full: '' };
|
||||
|
||||
try {
|
||||
const json = JSON.parse(result);
|
||||
|
||||
// Search results
|
||||
if (json.results && Array.isArray(json.results) && json.query) {
|
||||
const count = json.resultCount || json.results.length;
|
||||
return {
|
||||
type: 'search',
|
||||
summary: `Found ${count} results for "${json.query}"`,
|
||||
full: result
|
||||
};
|
||||
}
|
||||
|
||||
// Location result
|
||||
if (json.location) {
|
||||
const loc = json.location;
|
||||
const place = loc.city ? `${loc.city}, ${loc.country || ''}` : 'Location detected';
|
||||
return { type: 'location', summary: place, full: result };
|
||||
}
|
||||
|
||||
// Fetch result with content/text
|
||||
if (json.content || json.text) {
|
||||
const text = json.content || json.text;
|
||||
const title = json.title || json.url || 'Fetched content';
|
||||
const chars = typeof text === 'string' ? text.length : 0;
|
||||
return {
|
||||
type: 'fetch',
|
||||
summary: `${title} (${formatBytes(chars)} chars)`,
|
||||
full: typeof text === 'string' ? text : result
|
||||
};
|
||||
}
|
||||
|
||||
// Generic JSON
|
||||
return {
|
||||
type: 'json',
|
||||
summary: `JSON response (${formatBytes(result.length)})`,
|
||||
full: JSON.stringify(json, null, 2)
|
||||
};
|
||||
} catch {
|
||||
// Plain text result
|
||||
const lines = result.split('\n').length;
|
||||
const chars = result.length;
|
||||
return {
|
||||
type: 'text',
|
||||
summary: `${lines} lines, ${formatBytes(chars)}`,
|
||||
full: result
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format byte size for display
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1000) return `${bytes}`;
|
||||
if (bytes < 1000000) return `${(bytes / 1000).toFixed(1)}K`;
|
||||
return `${(bytes / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
// Collapsed state per tool (arguments section)
|
||||
let expandedCalls = $state<Set<string>>(new Set());
|
||||
// Collapsed state for results (separate, collapsed by default)
|
||||
let expandedResults = $state<Set<string>>(new Set());
|
||||
|
||||
function toggleExpand(id: string): void {
|
||||
if (expandedCalls.has(id)) {
|
||||
@@ -101,6 +168,15 @@
|
||||
}
|
||||
expandedCalls = new Set(expandedCalls);
|
||||
}
|
||||
|
||||
function toggleResult(id: string): void {
|
||||
if (expandedResults.has(id)) {
|
||||
expandedResults.delete(id);
|
||||
} else {
|
||||
expandedResults.add(id);
|
||||
}
|
||||
expandedResults = new Set(expandedResults);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="my-3 space-y-2">
|
||||
@@ -162,7 +238,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Expanded details -->
|
||||
<!-- Expanded arguments -->
|
||||
{#if isExpanded && argEntries.length > 0}
|
||||
<div class="border-t border-slate-800 px-4 py-3">
|
||||
<div class="space-y-2">
|
||||
@@ -179,6 +255,51 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Result section (collapsed by default) -->
|
||||
{#if call.result || call.error}
|
||||
{@const hasResult = !!call.result}
|
||||
{@const parsed = parseResult(call.result)}
|
||||
{@const isResultExpanded = expandedResults.has(call.id)}
|
||||
|
||||
<div class="border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleResult(call.id)}
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition-colors hover:bg-slate-800/50"
|
||||
>
|
||||
<!-- Status icon -->
|
||||
{#if call.error}
|
||||
<span class="text-red-400">✗</span>
|
||||
<span class="flex-1 text-red-300">Error: {call.error}</span>
|
||||
{:else}
|
||||
<span class="text-emerald-400">✓</span>
|
||||
<span class="flex-1 text-slate-400">{parsed.summary}</span>
|
||||
{/if}
|
||||
|
||||
<!-- Expand arrow -->
|
||||
{#if hasResult && parsed.full}
|
||||
<svg
|
||||
class="h-4 w-4 flex-shrink-0 text-slate-500 transition-transform duration-200"
|
||||
class:rotate-180={isResultExpanded}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Expanded result content -->
|
||||
{#if isResultExpanded && hasResult && parsed.full}
|
||||
<div class="max-h-96 overflow-auto border-t border-slate-800/50 bg-slate-950/50 px-4 py-3">
|
||||
<pre class="whitespace-pre-wrap break-words text-xs text-slate-400">{parsed.full.length > 10000 ? parsed.full.substring(0, 10000) + '\n\n... (truncated)' : parsed.full}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -23,8 +23,21 @@ export class ChatState {
|
||||
streamingMessageId = $state<string | null>(null);
|
||||
streamBuffer = $state('');
|
||||
|
||||
// Derived: Get visible messages along the active path
|
||||
// Derived: Get visible messages along the active path (excluding hidden messages for UI)
|
||||
visibleMessages = $derived.by(() => {
|
||||
const messages: MessageNode[] = [];
|
||||
for (const id of this.activePath) {
|
||||
const node = this.messageTree.get(id);
|
||||
// Skip hidden messages (e.g., internal tool result context)
|
||||
if (node && !node.message.hidden) {
|
||||
messages.push(node);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
});
|
||||
|
||||
// Derived: Get ALL messages along active path (including hidden, for API calls)
|
||||
allMessages = $derived.by(() => {
|
||||
const messages: MessageNode[] = [];
|
||||
for (const id of this.activePath) {
|
||||
const node = this.messageTree.get(id);
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
/** Result of the tool call (stored after execution) */
|
||||
result?: string;
|
||||
/** Error message if tool call failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A single chat message */
|
||||
@@ -18,6 +22,8 @@ export interface Message {
|
||||
content: string;
|
||||
images?: string[];
|
||||
toolCalls?: ToolCall[];
|
||||
/** If true, message is hidden from UI (e.g., internal tool result messages) */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
/** A node in the message tree structure (for branching conversations) */
|
||||
|
||||
@@ -272,9 +272,10 @@
|
||||
conversationId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Convert tool calls to executor format
|
||||
const convertedCalls = toolCalls.map(tc => ({
|
||||
id: crypto.randomUUID(),
|
||||
// Convert tool calls to executor format with stable IDs
|
||||
const callIds = toolCalls.map(() => crypto.randomUUID());
|
||||
const convertedCalls = toolCalls.map((tc, i) => ({
|
||||
id: callIds[i],
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments
|
||||
}));
|
||||
@@ -282,33 +283,26 @@
|
||||
// Execute all tools (including custom tools)
|
||||
const results = await runToolCalls(convertedCalls, undefined, toolsState.customTools);
|
||||
|
||||
// Format results for chat
|
||||
// Format results for model context (still needed for LLM to respond)
|
||||
const toolResultContent = formatToolResultsForChat(results);
|
||||
|
||||
// Update assistant message with tool info
|
||||
const toolCallInfo = toolCalls
|
||||
.map(tc => `Called tool: ${tc.function.name}(${JSON.stringify(tc.function.arguments)})`)
|
||||
.join('\n');
|
||||
|
||||
const assistantNode = chatState.messageTree.get(assistantMessageId);
|
||||
if (assistantNode) {
|
||||
// Preserve any thinking content that was already streamed
|
||||
const existingContent = assistantNode.message.content || '';
|
||||
const newContent = toolCallInfo + '\n\n' + toolResultContent;
|
||||
// Store structured tool call data with results for display
|
||||
// Results are shown collapsed in ToolCallDisplay - NOT as raw message content
|
||||
assistantNode.message.toolCalls = toolCalls.map((tc, i) => {
|
||||
const result = results[i];
|
||||
return {
|
||||
id: callIds[i],
|
||||
name: tc.function.name,
|
||||
arguments: JSON.stringify(tc.function.arguments),
|
||||
result: result.success ? (typeof result.result === 'object' ? JSON.stringify(result.result) : String(result.result)) : undefined,
|
||||
error: result.success ? undefined : result.error
|
||||
};
|
||||
});
|
||||
|
||||
// If there's existing content (like thinking), append tool info after it
|
||||
if (existingContent.trim()) {
|
||||
assistantNode.message.content = existingContent + '\n\n' + newContent;
|
||||
} else {
|
||||
assistantNode.message.content = newContent;
|
||||
}
|
||||
|
||||
// Store structured tool call data for display
|
||||
assistantNode.message.toolCalls = toolCalls.map(tc => ({
|
||||
id: crypto.randomUUID(),
|
||||
name: tc.function.name,
|
||||
arguments: JSON.stringify(tc.function.arguments)
|
||||
}));
|
||||
// DON'T add tool results to message content - that's what floods the UI
|
||||
// The results are stored in toolCalls and displayed by ToolCallDisplay
|
||||
}
|
||||
|
||||
// Persist tool call result (including any thinking content)
|
||||
@@ -319,10 +313,11 @@
|
||||
assistantMessageId
|
||||
);
|
||||
|
||||
// Add tool results as a message
|
||||
// Add tool results as a hidden message (for model context, not displayed in UI)
|
||||
const toolMessageId = chatState.addMessage({
|
||||
role: 'user',
|
||||
content: `Tool execution results:\n${toolResultContent}\n\nBased on these results, either provide a helpful response OR call another tool if you need more information.`
|
||||
content: `Tool execution results:\n${toolResultContent}\n\nBased on these results, either provide a helpful response OR call another tool if you need more information.`,
|
||||
hidden: true
|
||||
});
|
||||
|
||||
await addStoredMessage(
|
||||
@@ -335,7 +330,8 @@
|
||||
// Stream final response using original model - WITH tools so it can call more if needed
|
||||
const finalMessageId = chatState.startStreaming();
|
||||
|
||||
const allMessages = chatState.visibleMessages.map(node => ({
|
||||
// Use allMessages (including hidden) to send tool results to the model
|
||||
const apiMessages = chatState.allMessages.map(node => ({
|
||||
role: node.message.role,
|
||||
content: node.message.content,
|
||||
images: node.message.images
|
||||
@@ -348,7 +344,7 @@
|
||||
const tools = getToolsForApi();
|
||||
|
||||
await ollamaClient.streamChatWithCallbacks(
|
||||
{ model, messages: allMessages, tools },
|
||||
{ model, messages: apiMessages, tools },
|
||||
{
|
||||
onToken: (token) => {
|
||||
chatState.appendToStreaming(token);
|
||||
|
||||
Reference in New Issue
Block a user