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})`
+ })
+}