diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 99074c6..aea9f40 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/go.mod b/backend/go.mod index 97f8faa..0d38fc9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index be2d0af..c5df822 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/api/fetcher.go b/backend/internal/api/fetcher.go new file mode 100644 index 0000000..ee13000 --- /dev/null +++ b/backend/internal/api/fetcher.go @@ -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{ + `
`, + `
`, + `
`, + `
`, + `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)]*>.*?`) + content = scriptRe.ReplaceAllString(content, "") + + styleRe := regexp.MustCompile(`(?is)]*>.*?`) + 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) +} diff --git a/backend/internal/api/proxy.go b/backend/internal/api/proxy.go index 2a4ae57..3cb576b 100644 --- a/backend/internal/api/proxy.go +++ b/backend/internal/api/proxy.go @@ -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(), }) } } diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 0d7900e..0e19fb1 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -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()) diff --git a/backend/internal/api/search.go b/backend/internal/api/search.go index d57ca93..87dd74d 100644 --- a/backend/internal/api/search.go +++ b/backend/internal/api/search.go @@ -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), }) } } diff --git a/frontend/src/lib/components/chat/ChatWindow.svelte b/frontend/src/lib/components/chat/ChatWindow.svelte index b7d08c5..67a1ce2 100644 --- a/frontend/src/lib/components/chat/ChatWindow.svelte +++ b/frontend/src/lib/components/chat/ChatWindow.svelte @@ -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) { diff --git a/frontend/src/lib/components/chat/ToolCallDisplay.svelte b/frontend/src/lib/components/chat/ToolCallDisplay.svelte index df629bb..947c3f4 100644 --- a/frontend/src/lib/components/chat/ToolCallDisplay.svelte +++ b/frontend/src/lib/components/chat/ToolCallDisplay.svelte @@ -1,7 +1,7 @@
@@ -162,7 +238,7 @@ - + {#if isExpanded && argEntries.length > 0}
@@ -179,6 +255,51 @@
{/if} + + + {#if call.result || call.error} + {@const hasResult = !!call.result} + {@const parsed = parseResult(call.result)} + {@const isResultExpanded = expandedResults.has(call.id)} + +
+ + + + {#if isResultExpanded && hasResult && parsed.full} +
+
{parsed.full.length > 10000 ? parsed.full.substring(0, 10000) + '\n\n... (truncated)' : parsed.full}
+
+ {/if} +
+ {/if}
{/each} diff --git a/frontend/src/lib/stores/chat.svelte.ts b/frontend/src/lib/stores/chat.svelte.ts index 68d04c8..7fab06d 100644 --- a/frontend/src/lib/stores/chat.svelte.ts +++ b/frontend/src/lib/stores/chat.svelte.ts @@ -23,8 +23,21 @@ export class ChatState { streamingMessageId = $state(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); diff --git a/frontend/src/lib/types/chat.ts b/frontend/src/lib/types/chat.ts index 97fdefb..46a8cfc 100644 --- a/frontend/src/lib/types/chat.ts +++ b/frontend/src/lib/types/chat.ts @@ -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) */ diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index a65debf..fcf7679 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -272,9 +272,10 @@ conversationId: string ): Promise { 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);