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:
viettranx committed 2026-03-25 13:40:44 +07:00
1 parent cfb587e171
commit 5b614da90e
5 files changed
+69 -5

No files matched your search

+2 -2
View File
@@ -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)
+6 -1
View File
@@ -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))
}
+6 -1
View File
@@ -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))
}
+6 -1
View File
@@ -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))
}
+49
View File
@@ -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
}