mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 14:16:08 +00:00
fix(files): heal legacy media URLs and deduplicate file references
SignFileURLs now heals legacy session data at delivery time: - Dedup double /v1/files/v1/files/ prefix from old mutation bug - Strip stale ?ft= from markdown link display text - Re-sign all URLs with fresh tokens (never skip) File serving: use workspace root for ft= auth fallback search (no tenant context available with signed tokens). Deduplicate ContentSuffix media by basename to prevent duplicate file links when agent text already references the same file. Frontend dedup as defense-in-depth for legacy data.
This commit is contained in:
1 parent
101c4c45f5
commit
4a692d51d8
5 files changed
+141
-6
No files matched your search
@@ -48,8 +48,9 @@ func (l *Loop) finalizeRun(
|
||||
}
|
||||
|
||||
// Append content suffix (e.g. image markdown for WS) before saving to session.
|
||||
if req.ContentSuffix != "" && !strings.Contains(rs.finalContent, req.ContentSuffix) {
|
||||
rs.finalContent += req.ContentSuffix
|
||||
// Dedup by basename: skip suffix lines whose file already appears in the agent's text.
|
||||
if req.ContentSuffix != "" {
|
||||
rs.finalContent += deduplicateMediaSuffix(rs.finalContent, req.ContentSuffix)
|
||||
}
|
||||
|
||||
// Collect forwarded media + dedup + populate sizes BEFORE saving to session,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// deduplicateMediaSuffix filters ContentSuffix lines, removing any whose file
|
||||
// basename already appears in the main content. This prevents duplicate file
|
||||
// references when the agent's own text already includes the same media.
|
||||
//
|
||||
// ContentSuffix format (from mediaToMarkdown):
|
||||
//
|
||||
// \n\n
|
||||
// [filename.md](/v1/files/path/to/filename.md)
|
||||
func deduplicateMediaSuffix(content, suffix string) string {
|
||||
lines := strings.Split(suffix, "\n")
|
||||
var kept []string
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
kept = append(kept, line)
|
||||
continue
|
||||
}
|
||||
// Extract basename from the URL in markdown link/image: ](url) or ](url)
|
||||
base := extractLinkBasename(line)
|
||||
if base != "" && containsMediaRef(content, base) {
|
||||
continue // already referenced in content
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
result := strings.Join(kept, "\n")
|
||||
// If only empty lines remain, return empty string.
|
||||
if strings.TrimSpace(result) == "" {
|
||||
return ""
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// containsMediaRef checks if content contains a URL reference to a file by basename.
|
||||
// Requires URL context (slash before, paren or query after) to avoid false positives
|
||||
// matching prose text like "I saved output.png".
|
||||
func containsMediaRef(content, basename string) bool {
|
||||
return strings.Contains(content, "/"+basename+")") ||
|
||||
strings.Contains(content, "/"+basename+"?") ||
|
||||
strings.Contains(content, "/"+basename+"\n") ||
|
||||
strings.HasSuffix(content, "/"+basename)
|
||||
}
|
||||
|
||||
// extractLinkBasename extracts the filename (basename) from a markdown link or
|
||||
// image line. Handles: , [text](url).
|
||||
func extractLinkBasename(line string) string {
|
||||
// Find the URL between ]( and )
|
||||
idx := strings.Index(line, "](")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
urlStart := idx + 2
|
||||
urlEnd := strings.LastIndex(line, ")")
|
||||
if urlEnd <= urlStart {
|
||||
return ""
|
||||
}
|
||||
url := line[urlStart:urlEnd]
|
||||
// Strip query params before taking basename.
|
||||
if qIdx := strings.Index(url, "?"); qIdx > 0 {
|
||||
url = url[:qIdx]
|
||||
}
|
||||
return filepath.Base(url)
|
||||
}
|
||||
@@ -70,12 +70,21 @@ var fileURLRe = regexp.MustCompile(`(/v1/(?:files|media)/[^\s)"'<>]+)`)
|
||||
|
||||
// SignFileURLs finds all /v1/files/ and /v1/media/ URLs in content and appends
|
||||
// a signed ?ft= token. Used at delivery time (WS events, HTTP responses) to avoid
|
||||
// persisting tokens in session messages. Strips any stale ?ft= tokens from legacy
|
||||
// data before re-signing with a fresh token.
|
||||
// persisting tokens in session messages. Also heals legacy data issues:
|
||||
// - Strips stale ?ft= tokens and re-signs with fresh tokens
|
||||
// - Fixes double /v1/files/v1/files/ prefix from old media mutation bug
|
||||
// - Cleans ?ft= from markdown link display text [name?ft=xxx](url) → [name](url)
|
||||
func SignFileURLs(content, secret string) string {
|
||||
if secret == "" || !strings.Contains(content, "/v1/") {
|
||||
return content
|
||||
}
|
||||
// Heal legacy: deduplicate /v1/files/v1/files/ → /v1/files/ and /v1/media/v1/media/ → /v1/media/
|
||||
content = strings.ReplaceAll(content, "/v1/files/v1/files/", "/v1/files/")
|
||||
content = strings.ReplaceAll(content, "/v1/files/v1/media/", "/v1/media/")
|
||||
content = strings.ReplaceAll(content, "/v1/media/v1/media/", "/v1/media/")
|
||||
// Heal legacy: clean ?ft=... from markdown link display text [name?ft=xxx](...) → [name](...)
|
||||
content = linkTextFtRe.ReplaceAllString(content, "[$1]")
|
||||
// Sign URLs
|
||||
return fileURLRe.ReplaceAllStringFunc(content, func(url string) string {
|
||||
// Strip stale ft= token if present (legacy data may have persisted tokens).
|
||||
cleanURL := stripFileToken(url)
|
||||
@@ -88,6 +97,10 @@ func SignFileURLs(content, secret string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// linkTextFtRe matches markdown link text containing ?ft=... e.g. [filename.md?ft=xxx](...)
|
||||
// Captures the clean name before ?ft= for replacement.
|
||||
var linkTextFtRe = regexp.MustCompile(`\[([^\]\s?]+)\?ft=[^\]]*\]`)
|
||||
|
||||
// staleTokenRe matches ?ft=... or &ft=... query parameter (greedy to end of URL segment).
|
||||
var staleTokenRe = regexp.MustCompile(`[?&]ft=[^\s)"'<>&]*`)
|
||||
|
||||
|
||||
@@ -110,8 +110,12 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
// Workspace scoped to tenant to prevent cross-tenant file discovery.
|
||||
// For ft= signed requests, search from workspace root (no tenant context available);
|
||||
// for bearer requests, scope to tenant workspace.
|
||||
ws := h.tenantWorkspace(r)
|
||||
if r.URL.Query().Get("ft") != "" {
|
||||
ws = h.workspace // ft= auth has no tenant context; path is cryptographically bound
|
||||
}
|
||||
if resolved := h.findInWorkspace(ws, filepath.Base(absPath)); resolved != "" {
|
||||
absPath = resolved
|
||||
info, _ = os.Stat(absPath)
|
||||
|
||||
@@ -251,6 +251,55 @@ function VideoNoticeBadge({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Media link dedup ---
|
||||
|
||||
/** Extract basename from a markdown link/image URL, stripping query params. */
|
||||
function linkBasename(line: string): string | null {
|
||||
const m = line.match(/\]\(([^)]+)\)/);
|
||||
if (!m?.[1]) return null;
|
||||
const url = m[1].split("?")[0] ?? "";
|
||||
const slash = url.lastIndexOf("/");
|
||||
return slash >= 0 ? url.slice(slash + 1) : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicate media links from content. When the same file (by basename)
|
||||
* appears in both the agent's body text and an appended ContentSuffix block,
|
||||
* keep the first occurrence and drop later duplicates.
|
||||
*/
|
||||
function deduplicateMediaLinks(content: string): string {
|
||||
const lines = content.split("\n");
|
||||
// Pass 1: collect first occurrence index of each media basename (inline or standalone).
|
||||
const firstSeen = new Map<string, number>();
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
if (!line.includes("](/")) continue;
|
||||
const base = linkBasename(line);
|
||||
if (base && !firstSeen.has(base)) firstSeen.set(base, i);
|
||||
}
|
||||
// Pass 2: drop STANDALONE link/image lines whose basename appeared in an earlier line.
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const trimmed = line.trim();
|
||||
const isStandaloneLink = (trimmed.startsWith(" && trimmed.endsWith(")");
|
||||
if (isStandaloneLink) {
|
||||
const base = linkBasename(trimmed);
|
||||
if (base) {
|
||||
if (seen.has(base)) continue; // duplicate standalone — drop
|
||||
seen.add(base);
|
||||
// Also skip if this basename appeared inline in an earlier line
|
||||
const first = firstSeen.get(base);
|
||||
if (first !== undefined && first < i) continue;
|
||||
}
|
||||
}
|
||||
result.push(line);
|
||||
}
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
interface RichContentProps {
|
||||
@@ -259,7 +308,7 @@ interface RichContentProps {
|
||||
}
|
||||
|
||||
export function RichContent({ content, role }: RichContentProps) {
|
||||
const blocks = parseRichContent(content);
|
||||
const blocks = parseRichContent(deduplicateMediaLinks(content));
|
||||
|
||||
// If no special blocks found, render as plain markdown (fast path)
|
||||
const first = blocks[0];
|
||||
|
||||
Reference in new issue
Block a user