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:
2025-12-31 23:52:57 +01:00
parent 15084fb3ca
commit c0ef31e5f4
12 changed files with 930 additions and 137 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) */

View File

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