fix(files): fallback workspace search for generated file links

LLMs hallucinate file paths in responses (e.g. ./system/generated/file.png)
when the actual file is at /app/workspace/teams/.../generated/file.png.

Backend: /v1/files/ now falls back to searching the workspace directory
tree by basename when exact path isn't found. Generated filenames
(goclaw_gen_*) include nanosecond timestamps and are globally unique.

Frontend: relative file paths now use just the basename in the /v1/files/
URL, letting the backend fallback find the actual file. Auth token
appended as ?token= query param for authenticated file access.
This commit is contained in:
viettranx committed 2026-03-20 19:57:39 +07:00
1 parent ca6ec0bf9f
commit 001fe2721c
3 files changed
+70 -10

No files matched your search

+1 -1
View File
@@ -386,7 +386,7 @@ func runGateway() {
// Workspace file serving endpoint — serves files by absolute path, auth-token protected.
// Supports media from any agent workspace (each agent has its own workspace from DB).
server.SetFilesHandler(httpapi.NewFilesHandler(cfg.Gateway.Token))
server.SetFilesHandler(httpapi.NewFilesHandler(cfg.Gateway.Token, workspace))
// Storage file management — browse/delete files under the resolved workspace directory.
// Uses GOCLAW_WORKSPACE (or default ~/.goclaw/workspace) so it works correctly
+62 -5
View File
@@ -13,13 +13,17 @@ 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).
type FilesHandler struct {
token string
token string
workspace string // workspace root for fallback file search
}
// NewFilesHandler creates a handler that serves files by absolute path.
func NewFilesHandler(token string) *FilesHandler {
return &FilesHandler{token: token}
// workspace is the root directory used for fallback generated file search.
func NewFilesHandler(token, workspace string) *FilesHandler {
return &FilesHandler{token: token, workspace: workspace}
}
// RegisterRoutes registers the file serving route.
@@ -79,8 +83,15 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
info, err := os.Stat(absPath)
if err != nil || info.IsDir() {
http.NotFound(w, r)
return
// Fallback: search workspace for file by basename (handles LLM-hallucinated paths).
// Generated filenames (goclaw_gen_*) include nanosecond timestamps and are globally unique.
if resolved := h.findInWorkspace(filepath.Base(absPath)); resolved != "" {
absPath = resolved
info, _ = os.Stat(absPath)
} else {
http.NotFound(w, r)
return
}
}
// Set Content-Type from extension
@@ -92,3 +103,49 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, absPath)
}
// findInWorkspace searches the workspace directory tree for a file by basename.
// Returns the absolute path if found, empty string otherwise.
// Only searches under "generated/" subdirectories to limit scope.
func (h *FilesHandler) findInWorkspace(basename string) string {
if h.workspace == "" || basename == "" {
return ""
}
var found string
_ = filepath.WalkDir(h.workspace, func(path string, d os.DirEntry, err error) error {
if err != nil {
return filepath.SkipDir
}
if d.IsDir() {
// Only descend into "generated" directories (and their parents).
name := d.Name()
if name == "generated" || name == "teams" || name == h.workspace {
return nil
}
// Allow date directories under generated/ (e.g. 2026-03-20)
if len(name) == 10 && name[4] == '-' {
return nil
}
// Allow team/user ID directories
if strings.Contains(name, "-") || isNumeric(name) {
return nil
}
return filepath.SkipDir
}
if d.Name() == basename {
found = path
return filepath.SkipAll
}
return nil
})
return found
}
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
@@ -63,15 +63,18 @@ function isFileLink(href: string | undefined): boolean {
return false;
}
/** Convert a local file path to a /v1/files/ URL for serving */
/** Convert a local file path to a /v1/files/ URL for serving.
* For relative paths, uses just the filename so the backend fallback search
* can find generated files regardless of the directory the LLM wrote. */
function toFileUrl(href: string, token?: string): string {
let url: string;
if (href.startsWith("/v1/files/") || href.includes("/v1/files/")) {
url = href;
} else {
// For relative paths, strip leading ./ or ../ and route through /v1/files/
const cleaned = href.replace(/^\.\.?\//, "");
url = `/v1/files/${cleaned}`;
// For relative paths, use just the basename — the backend will search
// the workspace for the file by name (goclaw_gen_* names are unique).
const basename = href.split("/").pop() ?? href;
url = `/v1/files/${basename}`;
}
// Append auth token as query param (server accepts ?token= for file serving)
if (token) {