mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-31 02:24:41 +00:00
- Add persistent media storage (internal/media/) replacing temp file deletion
- Add MediaRef type for lightweight media references in session messages
- Refactor media pipeline to use bus.MediaFile{Path, MimeType} across all channels
- Add read_document builtin tool for PDF/DOCX/XLSX analysis via Gemini native API
- Move image sanitization from Telegram to shared agent/media layer
- Add media reload for multi-turn conversations (images from last 5 messages)
- Add reply-to-message media resolution for Telegram (re-download on reply)
- Add media inventory to compaction summary to preserve awareness after truncation
- Fix coreToolSummaries for read_image, read_document, create_image tools
- Add real-time trace update events via WebSocket broadcast
- Improve trace detail UI with media refs and tool result display
200 lines
6.3 KiB
Go
200 lines
6.3 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
)
|
|
|
|
// documentProviderPriority is the order in which providers are tried for document analysis.
|
|
// Gemini has best native PDF support (50MB, 258 tokens/page).
|
|
var documentProviderPriority = []string{"gemini", "anthropic", "openrouter", "dashscope"}
|
|
|
|
// documentModelOverrides maps provider names to preferred document-capable models.
|
|
var documentModelOverrides = map[string]string{
|
|
"gemini": "gemini-2.5-flash",
|
|
"openrouter": "google/gemini-2.5-flash",
|
|
"dashscope": "qwen-vl-max",
|
|
}
|
|
|
|
// resolveDocumentFile finds the document file path from context MediaRefs.
|
|
func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID string) (path, mime string, err error) {
|
|
if t.mediaLoader == nil {
|
|
return "", "", fmt.Errorf("no media storage configured — cannot access document files")
|
|
}
|
|
|
|
refs := MediaDocRefsFromCtx(ctx)
|
|
if len(refs) == 0 {
|
|
return "", "", fmt.Errorf("no documents available in this conversation. The user may not have sent a document.")
|
|
}
|
|
|
|
// Find specific media_id or use most recent document.
|
|
var ref *providers.MediaRef
|
|
if mediaID != "" {
|
|
for i := range refs {
|
|
if refs[i].ID == mediaID {
|
|
ref = &refs[i]
|
|
break
|
|
}
|
|
}
|
|
if ref == nil {
|
|
return "", "", fmt.Errorf("document with media_id %q not found in conversation", mediaID)
|
|
}
|
|
} else {
|
|
// Use the last (most recent) document ref.
|
|
ref = &refs[len(refs)-1]
|
|
}
|
|
|
|
p, err := t.mediaLoader.LoadPath(ref.ID)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("document file not found: %v", err)
|
|
}
|
|
|
|
// Determine MIME type: prefer ref's stored MIME, fall back to extension.
|
|
mime = ref.MimeType
|
|
if mime == "" || mime == "application/octet-stream" {
|
|
mime = mimeFromDocExt(filepath.Ext(p))
|
|
}
|
|
|
|
return p, mime, nil
|
|
}
|
|
|
|
// resolveDocumentProviderWithConfig checks per-agent config, global settings, then hardcoded priority.
|
|
func (t *ReadDocumentTool) resolveDocumentProviderWithConfig(ctx context.Context) (providers.Provider, string, error) {
|
|
// 1. Global builtin_tools.settings
|
|
if p, model, ok := t.resolveFromBuiltinSettings(ctx); ok {
|
|
return p, model, nil
|
|
}
|
|
// 2. Hardcoded defaults
|
|
return t.resolveDocumentProvider()
|
|
}
|
|
|
|
// resolveFromBuiltinSettings checks global builtin tool settings for provider/model config.
|
|
func (t *ReadDocumentTool) resolveFromBuiltinSettings(ctx context.Context) (providers.Provider, string, bool) {
|
|
settings := BuiltinToolSettingsFromCtx(ctx)
|
|
if settings == nil {
|
|
return nil, "", false
|
|
}
|
|
raw, ok := settings["read_document"]
|
|
if !ok || len(raw) == 0 {
|
|
return nil, "", false
|
|
}
|
|
var cfg struct {
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
}
|
|
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.Provider == "" {
|
|
return nil, "", false
|
|
}
|
|
p, err := t.registry.Get(cfg.Provider)
|
|
if err != nil {
|
|
return nil, "", false
|
|
}
|
|
model := cfg.Model
|
|
if model == "" {
|
|
model = p.DefaultModel()
|
|
}
|
|
return p, model, true
|
|
}
|
|
|
|
// resolveDocumentProvider finds the first available document-capable provider.
|
|
func (t *ReadDocumentTool) resolveDocumentProvider() (providers.Provider, string, error) {
|
|
for _, name := range documentProviderPriority {
|
|
p, err := t.registry.Get(name)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
model := p.DefaultModel()
|
|
if override, ok := documentModelOverrides[name]; ok {
|
|
model = override
|
|
}
|
|
return p, model, nil
|
|
}
|
|
return nil, "", fmt.Errorf("no document-capable provider available (need one of: %v)", documentProviderPriority)
|
|
}
|
|
|
|
// callDocumentProvider sends a document to a provider for analysis.
|
|
// For Gemini providers, uses native generateContent API (supports PDF natively).
|
|
// For others, falls back to OpenAI-compat chat with base64 document.
|
|
func (t *ReadDocumentTool) callDocumentProvider(ctx context.Context, provider providers.Provider, model, prompt string, data []byte, mime string) (*providers.ChatResponse, string, string) {
|
|
provName := provider.Name()
|
|
|
|
// Gemini: use native API (OpenAI-compat endpoint doesn't support non-image MIME types).
|
|
if strings.HasPrefix(provName, "gemini") {
|
|
oaiProv, ok := provider.(*providers.OpenAIProvider)
|
|
if !ok {
|
|
slog.Warn("read_document: gemini provider is not OpenAIProvider", "provider", provName)
|
|
return nil, "", ""
|
|
}
|
|
apiKey := oaiProv.APIKey()
|
|
slog.Info("read_document: using gemini native API",
|
|
"provider", provName, "model", model,
|
|
"doc_size", len(data), "mime", mime)
|
|
resp, err := geminiNativeDocumentCall(ctx, apiKey, model, prompt, data, mime)
|
|
if err != nil {
|
|
slog.Warn("read_document: gemini native call failed", "error", err)
|
|
return nil, "", ""
|
|
}
|
|
return resp, provName, model
|
|
}
|
|
|
|
// Other providers: use standard Chat API with document as base64 image_url.
|
|
slog.Info("read_document: using chat API", "provider", provName, "model", model, "doc_size", len(data))
|
|
resp, err := provider.Chat(ctx, providers.ChatRequest{
|
|
Messages: []providers.Message{
|
|
{
|
|
Role: "user",
|
|
Content: prompt,
|
|
Images: []providers.ImageContent{{MimeType: mime, Data: base64.StdEncoding.EncodeToString(data)}},
|
|
},
|
|
},
|
|
Model: model,
|
|
Options: map[string]interface{}{
|
|
"max_tokens": 16384,
|
|
"temperature": 0.2,
|
|
},
|
|
})
|
|
if err != nil {
|
|
slog.Warn("read_document: chat call failed", "provider", provName, "error", err)
|
|
return nil, "", ""
|
|
}
|
|
return resp, provName, model
|
|
}
|
|
|
|
// resolveDocumentProviderByName gets a specific provider by name and applies model override.
|
|
func (t *ReadDocumentTool) resolveDocumentProviderByName(name string) (providers.Provider, string, error) {
|
|
p, err := t.registry.Get(name)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
model := p.DefaultModel()
|
|
if override, ok := documentModelOverrides[name]; ok {
|
|
model = override
|
|
}
|
|
return p, model, nil
|
|
}
|
|
|
|
// mimeFromDocExt returns MIME type for document file extensions.
|
|
func mimeFromDocExt(ext string) string {
|
|
switch strings.ToLower(ext) {
|
|
case ".pdf":
|
|
return "application/pdf"
|
|
case ".doc", ".docx":
|
|
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
case ".xls", ".xlsx":
|
|
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
case ".ppt", ".pptx":
|
|
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
case ".csv":
|
|
return "text/csv"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|