From 5b614da90e5698f0acfff6dd6e21211c63bb1f27 Mon Sep 17 00:00:00 2001 From: viettranx Date: Wed, 25 Mar 2026 13:40:44 +0700 Subject: [PATCH] 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}. --- internal/http/files.go | 4 +-- internal/tools/create_audio.go | 7 ++++- internal/tools/create_image.go | 7 ++++- internal/tools/create_video.go | 7 ++++- internal/tools/media_filename.go | 49 ++++++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 internal/tools/media_filename.go diff --git a/internal/http/files.go b/internal/http/files.go index 614e5fe4..ab13ca30 100644 --- a/internal/http/files.go +++ b/internal/http/files.go @@ -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) diff --git a/internal/tools/create_audio.go b/internal/tools/create_audio.go index 90e8d335..407b93ee 100644 --- a/internal/tools/create_audio.go +++ b/internal/tools/create_audio.go @@ -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)) } diff --git a/internal/tools/create_image.go b/internal/tools/create_image.go index 21ab2571..9d51e9e4 100644 --- a/internal/tools/create_image.go +++ b/internal/tools/create_image.go @@ -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)) } diff --git a/internal/tools/create_video.go b/internal/tools/create_video.go index 40e34dba..5b8acc0c 100644 --- a/internal/tools/create_video.go +++ b/internal/tools/create_video.go @@ -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)) } diff --git a/internal/tools/media_filename.go b/internal/tools/media_filename.go new file mode 100644 index 00000000..e3cb5408 --- /dev/null +++ b/internal/tools/media_filename.go @@ -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 +}