From bb946685b383631659aa2003bdedbbfaf384f6cd Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 3 Jul 2026 14:34:35 +0800 Subject: [PATCH] fix(chat): render generated-file download links with the file name, not the raw id URL (#466) --- .../agent/graph/node/FinalAnswerNode.java | 9 +- .../tool/document/GeneratedFileCache.java | 45 +++++++++ .../GeneratedFileCacheLinkifyTest.java | 98 +++++++++++++++++++ .../src/components/chat/ContentSegment.vue | 11 ++- .../src/components/chat/MessageBubble.vue | 11 ++- .../__tests__/generatedFileLinks.test.ts | 56 +++++++++++ mateclaw-ui/src/utils/generatedFileLinks.ts | 44 +++++++++ 7 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheLinkifyTest.java create mode 100644 mateclaw-ui/src/utils/__tests__/generatedFileLinks.test.ts create mode 100644 mateclaw-ui/src/utils/generatedFileLinks.ts diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java index 7ddec09d..3870c022 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java @@ -265,12 +265,15 @@ public class FinalAnswerNode implements NodeAction { /** * Replace fake {@code /api/v1/files/generated/{id}} URLs (cache-miss) - * with a user-visible warning. No-op when no cache is wired (legacy - * tests) or when the answer is empty. + * with a user-visible warning, and wrap live bare URLs into + * {@code [filename](url)} markdown links so the chat shows the file name + * instead of the raw id. No-op when no cache is wired (legacy tests) or + * when the answer is empty. */ private String scrubFakeUrls(String text) { if (generatedFileCache == null || text == null || text.isEmpty()) return text; - return generatedFileCache.scrubMissingReferences(text); + return generatedFileCache.linkifyBareReferences( + generatedFileCache.scrubMissingReferences(text)); } private FinishReason parseFinishReason(String reason) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java index 97dffcbc..40e2abb8 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java @@ -349,4 +349,49 @@ public class GeneratedFileCache { m.appendTail(out); return out.toString(); } + + /** + * Wrap bare {@code /api/v1/files/generated/{id}} URLs whose id is live + * into a {@code [filename](url)} markdown link, so chat surfaces render + * the file name instead of the raw id URL. Models frequently echo the + * download URL as plain text ("下载链接:http://…/{uuid}") even though the + * tool result hands them a ready-made markdown link; autolink rendering + * then displays the UUID to the user. + * + *

URLs already serving as a markdown link destination (directly + * preceded by {@code ](}) are left untouched, whatever their link text — + * the model may have chosen a legitimate custom label. Cache misses are + * also left untouched; {@link #scrubMissingReferences} owns that case. + */ + public String linkifyBareReferences(String text) { + if (text == null || text.isEmpty()) return text; + Matcher m = GENERATED_URL_PATTERN.matcher(text); + if (!m.find()) return text; + StringBuilder out = new StringBuilder(); + m.reset(); + while (m.find()) { + String replacement = m.group(0); + int s = m.start(); + boolean isLinkDestination = s >= 2 + && text.charAt(s - 1) == '(' + && text.charAt(s - 2) == ']'; + // Angle-bracket autolinks () must not be wrapped either — + // "[name]()" with the brackets kept inline breaks rendering. + boolean isAngleAutolink = s >= 1 && text.charAt(s - 1) == '<'; + if (!isLinkDestination && !isAngleAutolink) { + String filename = get(m.group(1)) + .map(Entry::filename) + .filter(n -> n != null && !n.isBlank()) + // Square brackets would terminate the link text early. + .map(n -> n.replaceAll("[\\[\\]]", "")) + .orElse(null); + if (filename != null) { + replacement = "[" + filename + "](" + m.group(0) + ")"; + } + } + m.appendReplacement(out, Matcher.quoteReplacement(replacement)); + } + m.appendTail(out); + return out.toString(); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheLinkifyTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheLinkifyTest.java new file mode 100644 index 00000000..a7ed653b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheLinkifyTest.java @@ -0,0 +1,98 @@ +package vip.mate.tool.document; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pin {@link GeneratedFileCache#linkifyBareReferences}: bare generated-file + * URLs echoed by the model as plain text must be wrapped into + * {@code [filename](url)} markdown links so chat surfaces show the file name + * instead of the raw id, while URLs already inside a markdown link stay + * untouched. + */ +class GeneratedFileCacheLinkifyTest { + + private GeneratedFileCache cache; + + @BeforeEach + void setUp(@TempDir Path tempDir) { + cache = new GeneratedFileCache(tempDir); + } + + private String putFile(String filename) { + return cache.put("dummy".getBytes(), filename, "application/octet-stream"); + } + + @Test + @DisplayName("bare relative URL with a live id → wrapped into [filename](url)") + void bareRelativeUrlWrapped() { + String id = putFile("智能体技术培训_红色版.pptx"); + String url = "/api/v1/files/generated/" + id; + String out = cache.linkifyBareReferences("下载链接:" + url + "(10 分钟内有效)"); + assertEquals("下载链接:[智能体技术培训_红色版.pptx](" + url + ")(10 分钟内有效)", out); + } + + @Test + @DisplayName("bare absolute URL keeps its host inside the link destination") + void bareAbsoluteUrlWrapped() { + String id = putFile("report.docx"); + String url = "http://localhost:55793/api/v1/files/generated/" + id; + String out = cache.linkifyBareReferences("下载:" + url); + assertEquals("下载:[report.docx](" + url + ")", out); + } + + @Test + @DisplayName("URL already used as a markdown link destination is left untouched") + void markdownLinkLeftUntouched() { + String id = putFile("slides.pptx"); + String text = "演示文稿已生成:[自定义标题](/api/v1/files/generated/" + id + ")"; + assertEquals(text, cache.linkifyBareReferences(text)); + } + + @Test + @DisplayName("angle-bracket autolink is left untouched") + void angleAutolinkLeftUntouched() { + String id = putFile("a.xlsx"); + String text = "见 处"; + assertEquals(text, cache.linkifyBareReferences(text)); + } + + @Test + @DisplayName("unknown id is left for the missing-reference scrubber") + void unknownIdLeftUntouched() { + String text = "文件:/api/v1/files/generated/a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + assertEquals(text, cache.linkifyBareReferences(text)); + } + + @Test + @DisplayName("square brackets in the stored filename are stripped from the link text") + void bracketsInFilenameStripped() { + String id = putFile("[草稿]方案.docx"); + String out = cache.linkifyBareReferences("/api/v1/files/generated/" + id); + assertTrue(out.startsWith("[草稿方案.docx]("), "brackets must be stripped; got: " + out); + } + + @Test + @DisplayName("mixed text: markdown link kept, bare duplicate of the same URL wrapped") + void mixedMarkdownAndBare() { + String id = putFile("数据.csv"); + String url = "/api/v1/files/generated/" + id; + String out = cache.linkifyBareReferences("[数据.csv](" + url + ") 备用地址 " + url); + assertEquals("[数据.csv](" + url + ") 备用地址 [数据.csv](" + url + ")", out); + } + + @Test + @DisplayName("null / empty / no-URL text passes through") + void passThrough() { + assertNull(cache.linkifyBareReferences(null)); + assertEquals("", cache.linkifyBareReferences("")); + String plain = "没有链接的普通回答"; + assertSame(plain, cache.linkifyBareReferences(plain)); + } +} diff --git a/mateclaw-ui/src/components/chat/ContentSegment.vue b/mateclaw-ui/src/components/chat/ContentSegment.vue index 0e3c3906..fe58e562 100644 --- a/mateclaw-ui/src/components/chat/ContentSegment.vue +++ b/mateclaw-ui/src/components/chat/ContentSegment.vue @@ -1,14 +1,18 @@ diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 852a1cc7..2ca034a6 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -75,6 +75,7 @@ v-if="!seg.superseded || isSupersededExpanded(seg.id)" :segment="seg" :show-cursor="showCursor && seg.status === 'running'" + :generated-file-names="generatedFileNames" :class="{ 'content-segment--superseded': seg.superseded }" /> @@ -535,6 +536,7 @@ import { WarningFilled, } from '@element-plus/icons-vue' import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown' +import { buildGeneratedFileNameMap, linkifyGeneratedFileUrls } from '@/utils/generatedFileLinks' import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment' import { useToolLabel } from '@/composables/useToolLabel' import { http } from '@/api' @@ -729,9 +731,16 @@ const displayContent = computed(() => { if (text && isApprovalPlaceholder(text)) return '' // 有错误卡片时隐藏 [错误] 原始文本,避免重复展示 if (status.value === 'failed' && errorInfo.value && text.startsWith('[错误]')) return '' - return text + return linkifyGeneratedFileUrls(text, generatedFileNames.value) }) +// id → filename map for generated-file downloads, sourced from the metadata +// the server builds out of tool results. Used to rewrite bare download URLs +// the model echoed as plain text into [name](url) links (the persisted copy +// is rewritten server-side; this covers the live-streamed bubble). +const generatedFileNames = computed(() => + buildGeneratedFileNameMap((props.message.metadata as any)?.generatedFiles)) + // --- parse_error detection --- const parseErrorText = computed(() => { const errorPart = props.message.contentParts?.find(p => p.type === 'parse_error') diff --git a/mateclaw-ui/src/utils/__tests__/generatedFileLinks.test.ts b/mateclaw-ui/src/utils/__tests__/generatedFileLinks.test.ts new file mode 100644 index 00000000..b92c4c0e --- /dev/null +++ b/mateclaw-ui/src/utils/__tests__/generatedFileLinks.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { buildGeneratedFileNameMap, linkifyGeneratedFileUrls } from '../generatedFileLinks' + +const FILES = [ + { name: '智能体技术培训_红色版.pptx', url: 'http://localhost:55793/api/v1/files/generated/ac38623b-7ed8-41f5-a80a-1e6761240ae0' }, + { name: 'report.docx', url: '/api/v1/files/generated/11111111-2222-3333-4444-555555555555' }, +] + +describe('buildGeneratedFileNameMap', () => { + it('maps ids from absolute and relative urls', () => { + const map = buildGeneratedFileNameMap(FILES) + expect(map.get('ac38623b-7ed8-41f5-a80a-1e6761240ae0')).toBe('智能体技术培训_红色版.pptx') + expect(map.get('11111111-2222-3333-4444-555555555555')).toBe('report.docx') + }) + + it('tolerates junk input', () => { + expect(buildGeneratedFileNameMap(null).size).toBe(0) + expect(buildGeneratedFileNameMap([{ name: 'x' }, { url: '/api/v1/files/generated/abc' }]).size).toBe(0) + }) +}) + +describe('linkifyGeneratedFileUrls', () => { + const names = buildGeneratedFileNameMap(FILES) + + it('wraps a bare absolute url into [name](url)', () => { + const text = '下载链接:http://localhost:55793/api/v1/files/generated/ac38623b-7ed8-41f5-a80a-1e6761240ae0(链接 10 分钟内有效)' + expect(linkifyGeneratedFileUrls(text, names)).toBe( + '下载链接:[智能体技术培训_红色版.pptx](http://localhost:55793/api/v1/files/generated/ac38623b-7ed8-41f5-a80a-1e6761240ae0)(链接 10 分钟内有效)', + ) + }) + + it('wraps a bare relative url', () => { + expect(linkifyGeneratedFileUrls('见 /api/v1/files/generated/11111111-2222-3333-4444-555555555555', names)) + .toBe('见 [report.docx](/api/v1/files/generated/11111111-2222-3333-4444-555555555555)') + }) + + it('leaves an existing markdown link untouched', () => { + const text = '[自定义标题](/api/v1/files/generated/11111111-2222-3333-4444-555555555555)' + expect(linkifyGeneratedFileUrls(text, names)).toBe(text) + }) + + it('leaves angle-bracket autolinks untouched', () => { + const text = '' + expect(linkifyGeneratedFileUrls(text, names)).toBe(text) + }) + + it('leaves unknown ids untouched', () => { + const text = '/api/v1/files/generated/99999999-0000-0000-0000-000000000000' + expect(linkifyGeneratedFileUrls(text, names)).toBe(text) + }) + + it('short-circuits when there is nothing to do', () => { + expect(linkifyGeneratedFileUrls('普通文本', names)).toBe('普通文本') + expect(linkifyGeneratedFileUrls('有 url /api/v1/files/generated/abc', new Map())).toBe('有 url /api/v1/files/generated/abc') + }) +}) diff --git a/mateclaw-ui/src/utils/generatedFileLinks.ts b/mateclaw-ui/src/utils/generatedFileLinks.ts new file mode 100644 index 00000000..9cda4368 --- /dev/null +++ b/mateclaw-ui/src/utils/generatedFileLinks.ts @@ -0,0 +1,44 @@ +/** + * Rewrites bare generated-file download URLs in assistant text into + * `[filename](url)` markdown links, so the chat renders the file name instead + * of the raw UUID URL. + * + * The backend does the same rewrite before persisting the final answer; this + * client-side pass covers the live-streamed bubble, whose text arrived as raw + * deltas before persistence. File names come from `metadata.generatedFiles`, + * which the server extracts from tool results during the same turn. + */ + +const GENERATED_URL_RE = /(\]\(|<)?(?:https?:\/\/[^\s)\]<>]+)?\/api\/v1\/files\/generated\/([a-zA-Z0-9-]+)/g +const GENERATED_ID_RE = /\/api\/v1\/files\/generated\/([a-zA-Z0-9-]+)/ + +export interface GeneratedFileRef { + name?: string + url?: string +} + +/** Build an id → display-name map from `metadata.generatedFiles`. */ +export function buildGeneratedFileNameMap(files: unknown): Map { + const names = new Map() + if (!Array.isArray(files)) return names + for (const f of files as GeneratedFileRef[]) { + const m = GENERATED_ID_RE.exec(String(f?.url || '')) + if (m && f?.name) names.set(m[1], String(f.name)) + } + return names +} + +/** + * Wrap bare generated-file URLs whose id has a known name into + * `[name](url)`. URLs already serving as a markdown link destination + * (preceded by `](`) or angle-bracket autolinks (``) are left as-is. + */ +export function linkifyGeneratedFileUrls(text: string, names: Map): string { + if (!text || !names.size || !text.includes('/api/v1/files/generated/')) return text + return text.replace(GENERATED_URL_RE, (full, prefix: string | undefined, id: string) => { + if (prefix) return full + const name = names.get(id) + if (!name) return full + return `[${name.replace(/[[\]]/g, '')}](${full})` + }) +}