diff --git a/cmd/gateway.go b/cmd/gateway.go index 610e14cb..7ecd98c6 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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 diff --git a/internal/http/files.go b/internal/http/files.go index bd439c75..fb4ea872 100644 --- a/internal/http/files.go +++ b/internal/http/files.go @@ -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 +} diff --git a/ui/web/src/components/shared/markdown-renderer.tsx b/ui/web/src/components/shared/markdown-renderer.tsx index 6c9e4b37..54ce9ffe 100644 --- a/ui/web/src/components/shared/markdown-renderer.tsx +++ b/ui/web/src/components/shared/markdown-renderer.tsx @@ -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) {