mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 18:18:37 +00:00
feat(tools): semantic media filenames with LLM-provided hints
Replace opaque goclaw_gen_{nano} filenames with semantic naming.
Tools now accept filename_hint param; fallback to {type}_{agentKey}_{ts}.
This commit is contained in:
1 parent
cfb587e171
commit
5b614da90e
5 files changed
+69
-5
No files matched your search
@@ -16,7 +16,7 @@ import (
|
||||
// FilesHandler serves files over HTTP with Bearer token auth.
|
||||
// Accepts absolute paths — the auth token protects against unauthorized access.
|
||||
// When an exact path is not found, falls back to searching the workspace for
|
||||
// generated files by basename (goclaw_gen_* filenames are globally unique).
|
||||
// generated files by basename (media filenames include timestamps and are globally unique).
|
||||
type FilesHandler struct {
|
||||
workspace string // workspace root for fallback file search
|
||||
dataDir string // data directory root for tenant path validation
|
||||
@@ -109,7 +109,7 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil || info.IsDir() {
|
||||
// Fallback: search workspace for file by basename (handles LLM-hallucinated paths).
|
||||
// Generated filenames (goclaw_gen_*) include nanosecond timestamps and are globally unique.
|
||||
// Generated media filenames include timestamps and are globally unique.
|
||||
// For ft= signed requests, search from workspace root (no tenant context available);
|
||||
// for bearer requests, scope to tenant workspace.
|
||||
ws := h.tenantWorkspace(r)
|
||||
|
||||
@@ -71,6 +71,10 @@ func (t *CreateAudioTool) Parameters() map[string]any {
|
||||
"type": "string",
|
||||
"description": "Force a specific provider (e.g. 'minimax').",
|
||||
},
|
||||
"filename_hint": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Short descriptive filename (no extension). Example: 'epic-battle-theme', 'rain-ambience'.",
|
||||
},
|
||||
},
|
||||
"required": []string{"prompt"},
|
||||
}
|
||||
@@ -95,6 +99,7 @@ func (t *CreateAudioTool) Execute(ctx context.Context, args map[string]any) *Res
|
||||
lyrics, _ := args["lyrics"].(string)
|
||||
instrumental, _ := args["instrumental"].(bool)
|
||||
forceProvider, _ := args["provider"].(string)
|
||||
filenameHint, _ := args["filename_hint"].(string)
|
||||
|
||||
var audioBytes []byte
|
||||
var usage *providers.Usage
|
||||
@@ -153,7 +158,7 @@ func (t *CreateAudioTool) Execute(ctx context.Context, args map[string]any) *Res
|
||||
if err := os.MkdirAll(dateDir, 0755); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to create output directory: %v", err))
|
||||
}
|
||||
audioPath := filepath.Join(dateDir, fmt.Sprintf("goclaw_gen_%d.mp3", time.Now().UnixNano()))
|
||||
audioPath := filepath.Join(dateDir, mediaFileName(ctx, "audio", filenameHint, "mp3"))
|
||||
if err := os.WriteFile(audioPath, audioBytes, 0644); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to save generated audio: %v", err))
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ func (t *CreateImageTool) Parameters() map[string]any {
|
||||
"type": "string",
|
||||
"description": "Aspect ratio: '1:1' (default), '3:4', '4:3', '9:16', '16:9'.",
|
||||
},
|
||||
"filename_hint": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Short descriptive filename (no extension). Example: 'sunset-beach', 'company-logo'.",
|
||||
},
|
||||
},
|
||||
"required": []string{"prompt"},
|
||||
}
|
||||
@@ -77,6 +81,7 @@ func (t *CreateImageTool) Execute(ctx context.Context, args map[string]any) *Res
|
||||
if aspectRatio == "" {
|
||||
aspectRatio = "1:1"
|
||||
}
|
||||
filenameHint, _ := args["filename_hint"].(string)
|
||||
|
||||
chain := ResolveMediaProviderChain(ctx, "create_image", "", "",
|
||||
imageGenProviderPriority, imageGenModelDefaults, t.registry)
|
||||
@@ -104,7 +109,7 @@ func (t *CreateImageTool) Execute(ctx context.Context, args map[string]any) *Res
|
||||
if err := os.MkdirAll(dateDir, 0755); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to create output directory: %v", err))
|
||||
}
|
||||
imagePath := filepath.Join(dateDir, fmt.Sprintf("goclaw_gen_%d.png", time.Now().UnixNano()))
|
||||
imagePath := filepath.Join(dateDir, mediaFileName(ctx, "image", filenameHint, "png"))
|
||||
if err := os.WriteFile(imagePath, chainResult.Data, 0644); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to save generated image: %v", err))
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ func (t *CreateVideoTool) Parameters() map[string]any {
|
||||
"type": "string",
|
||||
"description": "Aspect ratio: '16:9' (default) or '9:16'.",
|
||||
},
|
||||
"filename_hint": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Short descriptive filename (no extension). Example: 'cat-playing-piano', 'product-demo'.",
|
||||
},
|
||||
},
|
||||
"required": []string{"prompt"},
|
||||
}
|
||||
@@ -69,6 +73,7 @@ func (t *CreateVideoTool) Execute(ctx context.Context, args map[string]any) *Res
|
||||
if prompt == "" {
|
||||
return ErrorResult("prompt is required")
|
||||
}
|
||||
filenameHint, _ := args["filename_hint"].(string)
|
||||
|
||||
// Parse and enforce duration. Veo supports 4, 6, or 8 seconds.
|
||||
duration := 8
|
||||
@@ -122,7 +127,7 @@ func (t *CreateVideoTool) Execute(ctx context.Context, args map[string]any) *Res
|
||||
if err := os.MkdirAll(dateDir, 0755); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to create output directory: %v", err))
|
||||
}
|
||||
videoPath := filepath.Join(dateDir, fmt.Sprintf("goclaw_gen_%d.mp4", time.Now().UnixNano()))
|
||||
videoPath := filepath.Join(dateDir, mediaFileName(ctx, "video", filenameHint, "mp4"))
|
||||
if err := os.WriteFile(videoPath, chainResult.Data, 0644); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to save generated video: %v", err))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
var nonAlphanumRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// mediaFileName builds a semantic filename for generated media.
|
||||
// If hint is provided: {slug}_{YYYYMMDD-HHmmss}_{nano6}.{ext}
|
||||
// If hint is empty: {mediaType}_{agentKey}_{YYYYMMDD-HHmmss}_{nano6}.{ext}
|
||||
func mediaFileName(ctx context.Context, mediaType, hint, ext string) string {
|
||||
now := time.Now()
|
||||
ts := now.Format("20060102-150405")
|
||||
nano := fmt.Sprintf("%06d", now.UnixNano()%1_000_000)
|
||||
|
||||
var prefix string
|
||||
if slug := slugifyHint(hint, 50); slug != "" {
|
||||
prefix = slug
|
||||
} else {
|
||||
agentKey := store.AgentKeyFromContext(ctx)
|
||||
if agentKey == "" {
|
||||
agentKey = "gen"
|
||||
}
|
||||
prefix = mediaType + "_" + agentKey
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s_%s.%s", prefix, ts, nano, ext)
|
||||
}
|
||||
|
||||
// slugifyHint converts a hint string to a kebab-case slug, max maxLen chars.
|
||||
func slugifyHint(hint string, maxLen int) string {
|
||||
s := strings.ToLower(strings.TrimSpace(hint))
|
||||
s = nonAlphanumRe.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if len(s) > maxLen {
|
||||
s = s[:maxLen]
|
||||
if i := strings.LastIndex(s, "-"); i > 10 {
|
||||
s = s[:i]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in new issue
Block a user