mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-28 04:19:58 +00:00
245 lines
7.4 KiB
Go
245 lines
7.4 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
)
|
|
|
|
// resolveDocumentFile finds the document file path from an explicit workspace
|
|
// path or from context MediaRefs.
|
|
func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID, docPath string) (path, mime string, err error) {
|
|
if docPath != "" {
|
|
p, err := resolveDocumentPathArg(ctx, docPath)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return p, mimeFromDocExt(filepath.Ext(p)), nil
|
|
}
|
|
|
|
refs := MediaDocRefsFromCtx(ctx)
|
|
if len(refs) == 0 {
|
|
return "", "", fmt.Errorf("no documents available in this conversation. The user may not have sent a document.")
|
|
}
|
|
|
|
if strings.Contains(mediaID, "<") || strings.Contains(mediaID, "media:") {
|
|
slog.Debug("read_document: sanitizing tag-like media_id", "raw", mediaID)
|
|
mediaID = ""
|
|
}
|
|
|
|
// Find specific media_id or use most recent document.
|
|
var ref *providers.MediaRef
|
|
if mediaID != "" {
|
|
for i := range refs {
|
|
if documentRefMatches(refs[i], mediaID) {
|
|
ref = &refs[i]
|
|
break
|
|
}
|
|
}
|
|
if ref == nil {
|
|
return "", "", fmt.Errorf("document media_id %q not found in this conversation", mediaID)
|
|
}
|
|
} else {
|
|
// Use the last (most recent) document ref.
|
|
ref = &refs[len(refs)-1]
|
|
}
|
|
|
|
// Prefer persisted workspace path; fall back to legacy .media/ lookup.
|
|
p := ref.Path
|
|
if p == "" {
|
|
var err error
|
|
if t.mediaLoader == nil {
|
|
return "", "", fmt.Errorf("no media storage configured")
|
|
}
|
|
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
|
|
}
|
|
|
|
func resolveDocumentPathArg(ctx context.Context, path string) (string, error) {
|
|
workspace := ToolWorkspaceFromCtx(ctx)
|
|
resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, true), allowedWithTeamWorkspace(ctx, nil))
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid document path: %w", err)
|
|
}
|
|
if err := checkDeniedPath(resolved, workspace, nil); err != nil {
|
|
return "", err
|
|
}
|
|
if info, err := os.Stat(resolved); err != nil {
|
|
return "", fmt.Errorf("failed to stat document file: %w", err)
|
|
} else if info.IsDir() {
|
|
return "", fmt.Errorf("document path is a directory: %s", path)
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
func documentRefMatches(ref providers.MediaRef, mediaID string) bool {
|
|
if ref.ID == mediaID {
|
|
return true
|
|
}
|
|
if ref.Path == "" {
|
|
return false
|
|
}
|
|
want := strings.ToLower(filepath.Base(mediaID))
|
|
got := strings.ToLower(filepath.Base(ref.Path))
|
|
if want == got {
|
|
return true
|
|
}
|
|
return strings.EqualFold(stripUploadShortID(got), want)
|
|
}
|
|
|
|
func stripUploadShortID(name string) string {
|
|
ext := filepath.Ext(name)
|
|
stem := strings.TrimSuffix(name, ext)
|
|
idx := strings.LastIndexByte(stem, '-')
|
|
if idx < 0 || len(stem)-idx-1 != 8 {
|
|
return name
|
|
}
|
|
for _, r := range stem[idx+1:] {
|
|
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
|
return name
|
|
}
|
|
}
|
|
return stem[:idx] + ext
|
|
}
|
|
|
|
func isArchiveDocumentPath(path string) bool {
|
|
lower := strings.ToLower(path)
|
|
switch {
|
|
case strings.HasSuffix(lower, ".zip"),
|
|
strings.HasSuffix(lower, ".tar"),
|
|
strings.HasSuffix(lower, ".tar.gz"),
|
|
strings.HasSuffix(lower, ".tgz"),
|
|
strings.HasSuffix(lower, ".tar.bz2"),
|
|
strings.HasSuffix(lower, ".tbz2"),
|
|
strings.HasSuffix(lower, ".tar.xz"),
|
|
strings.HasSuffix(lower, ".txz"),
|
|
strings.HasSuffix(lower, ".gz"):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// callProvider dispatches document analysis to the appropriate provider API.
|
|
// For Gemini: uses native generateContent API (supports PDF natively).
|
|
// For others: uses standard Chat API with base64 document.
|
|
func (t *ReadDocumentTool) callProvider(ctx context.Context, cp credentialProvider, providerName, model string, params map[string]any) ([]byte, *providers.Usage, error) {
|
|
prompt := GetParamString(params, "prompt", "Analyze this document and describe its contents.")
|
|
data, _ := params["data"].([]byte)
|
|
mime := GetParamString(params, "mime", "application/octet-stream")
|
|
|
|
// Gemini: use native API (requires credentials; OpenAI-compat endpoint doesn't support non-image MIME types).
|
|
ptype := GetParamString(params, "_provider_type", providerTypeFromName(providerName))
|
|
if cp != nil && ptype == "gemini" {
|
|
slog.Info("read_document: using gemini native API",
|
|
"provider", providerName, "model", model,
|
|
"doc_size", len(data), "mime", mime)
|
|
chatReq := providers.ChatRequest{
|
|
Messages: []providers.Message{{
|
|
Role: "user",
|
|
Content: prompt,
|
|
Images: []providers.ImageContent{{MimeType: mime}},
|
|
}},
|
|
Model: model,
|
|
Options: map[string]any{"max_tokens": 16384},
|
|
}
|
|
reservation, reserveErr := reserveToolLLMUsage(ctx, t.usageCaps, t.Name(), providerName, model, chatReq)
|
|
if reserveErr != nil {
|
|
return nil, nil, reserveErr
|
|
}
|
|
resp, err := geminiNativeDocumentCall(ctx, cp.APIKey(), model, prompt, data, mime)
|
|
if reservation != nil {
|
|
reservation.Reconcile(ctx, resp, err)
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("gemini native call: %w", err)
|
|
}
|
|
return []byte(resp.Content), resp.Usage, nil
|
|
}
|
|
|
|
// Other providers: use standard Chat API with document as base64 image_url.
|
|
p, err := t.registry.Get(ctx, providerName)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("provider %q not available: %w", providerName, err)
|
|
}
|
|
|
|
slog.Info("read_document: using chat API", "provider", providerName, "model", model, "doc_size", len(data))
|
|
|
|
opts := map[string]any{
|
|
"max_tokens": 16384,
|
|
"temperature": 0.2,
|
|
}
|
|
// Scope disable_tools to claude-cli only — it's a CLI-bridge-specific
|
|
// option that skips loading the built-in MCP toolset for one-shot calls.
|
|
// Other providers silently ignore unknown keys today, but leaking
|
|
// provider-specific flags into the shared Options map couples layers.
|
|
if providerName == "claude-cli" {
|
|
opts["disable_tools"] = true
|
|
}
|
|
|
|
chatReq := providers.ChatRequest{
|
|
Messages: []providers.Message{
|
|
{
|
|
Role: "user",
|
|
Content: prompt,
|
|
Images: []providers.ImageContent{{MimeType: mime, Data: base64.StdEncoding.EncodeToString(data)}},
|
|
},
|
|
},
|
|
Model: model,
|
|
Options: opts,
|
|
}
|
|
reservation, reserveErr := reserveToolLLMUsage(ctx, t.usageCaps, t.Name(), providerName, model, chatReq)
|
|
if reserveErr != nil {
|
|
return nil, nil, reserveErr
|
|
}
|
|
resp, err := p.Chat(ctx, chatReq)
|
|
if reservation != nil {
|
|
reservation.Reconcile(ctx, resp, err)
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("chat call: %w", err)
|
|
}
|
|
return []byte(resp.Content), resp.Usage, 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"
|
|
case ".zip":
|
|
return "application/zip"
|
|
case ".tar":
|
|
return "application/x-tar"
|
|
case ".gz", ".tgz":
|
|
return "application/gzip"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|