fix(files): heal bare workspace paths and deduplicate media gallery vs markdown

SignFileURLs converts bare /app/workspace/ paths in markdown links to
/v1/files/ URLs so they can be served. File handler uses workspace root
for ft= auth fallback search (no tenant context with signed tokens).

Strip standalone media links from markdown when MediaGallery already
renders the same file (prevents image/file appearing twice). Backend
dedup uses URL-context basename matching to avoid false positives.
This commit is contained in:
viettranx committed 2026-03-25 12:51:57 +07:00
1 parent 20869275f2
commit 4c987aff69
3 files changed
+37 -3

No files matched your search

+8
View File
@@ -82,6 +82,9 @@ func SignFileURLs(content, secret string) string {
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: convert bare absolute paths in markdown links to /v1/files/ URLs.
// Agent text may embed ![img](/app/workspace/...) — these need the /v1/files/ prefix to be served.
content = barePathInLinkRe.ReplaceAllString(content, "](/v1/files$1)")
// Heal legacy: clean ?ft=... from markdown link display text [name?ft=xxx](...) → [name](...)
content = linkTextFtRe.ReplaceAllString(content, "[$1]")
// Sign URLs
@@ -101,6 +104,11 @@ func SignFileURLs(content, secret string) string {
// Captures the clean name before ?ft= for replacement.
var linkTextFtRe = regexp.MustCompile(`\[([^\]\s?]+)\?ft=[^\]]*\]`)
// barePathInLinkRe matches markdown link URLs that are bare absolute paths (not /v1/ URLs).
// Captures the absolute path so we can prepend /v1/files. Only matches inside ](...)
// to avoid false positives in prose text.
var barePathInLinkRe = regexp.MustCompile(`\]\((/(?:app|home|tmp|opt|data|workspace)[^\s)"'<>]*)\)`)
// staleTokenRe matches ?ft=... or &ft=... query parameter (greedy to end of URL segment).
var staleTokenRe = regexp.MustCompile(`[?&]ft=[^\s)"'<>&]*`)
@@ -69,7 +69,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
))}
</div>
)}
<MessageContent content={message.content} role={message.role} />
<MessageContent content={message.content} role={message.role} mediaBasenames={message.mediaItems?.map((m) => m.path.split("/").pop() ?? "").filter(Boolean)} />
{message.mediaItems && message.mediaItems.length > 0 && (
<div className="mt-2">
<MediaGallery items={message.mediaItems} />
+28 -2
View File
@@ -3,8 +3,34 @@ import { RichContent } from "./rich-content";
interface MessageContentProps {
content: string;
role: string;
/** Basenames of media files rendered separately by MediaGallery — strip from markdown. */
mediaBasenames?: string[];
}
export function MessageContent({ content, role }: MessageContentProps) {
return <RichContent content={content} role={role} />;
export function MessageContent({ content, role, mediaBasenames }: MessageContentProps) {
const cleaned = mediaBasenames?.length ? stripGalleryDuplicates(content, mediaBasenames) : content;
return <RichContent content={cleaned} role={role} />;
}
/**
* Remove standalone markdown image/link lines whose basename matches a MediaGallery item.
* This prevents the same file from appearing twice (once in markdown, once in gallery).
*/
function stripGalleryDuplicates(content: string, basenames: string[]): string {
if (!basenames.length) return content;
const baseSet = new Set(basenames);
return content
.split("\n")
.filter((line) => {
const trimmed = line.trim();
if (!(trimmed.startsWith("![") || trimmed.startsWith("[")) || !trimmed.includes("](/") || !trimmed.endsWith(")")) {
return true; // not a standalone link — keep
}
const m = trimmed.match(/\]\(([^)]+)\)/);
if (!m?.[1]) return true;
const url = m[1].split("?")[0] ?? "";
const base = url.split("/").pop() ?? "";
return !baseSet.has(base); // drop if gallery already shows this file
})
.join("\n");
}