fix(chat): render generated-file download links with the file name, not the raw id URL (#466)

This commit is contained in:
matevip 2026-07-03 14:34:35 +08:00
parent 25737495e5
commit bb946685b3
7 changed files with 269 additions and 5 deletions

View File

@ -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) {

View File

@ -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.
*
* <p>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 (<url>) must not be wrapped either
// "[name](<url>)" 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();
}
}

View File

@ -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 = "见 </api/v1/files/generated/" + id + "> 处";
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));
}
}

View File

@ -1,14 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
import { linkifyGeneratedFileUrls } from '@/utils/generatedFileLinks'
import TypingCursor from './TypingCursor.vue'
import type { MessageSegment } from '@/types'
const props = withDefaults(defineProps<{
segment: MessageSegment
showCursor?: boolean
/** id → filename map for rewriting bare generated-file URLs into [name](url). */
generatedFileNames?: Map<string, string>
}>(), {
showCursor: false,
generatedFileNames: undefined,
})
const isRunning = computed(() => props.segment.status === 'running')
@ -16,7 +20,12 @@ const isRunning = computed(() => props.segment.status === 'running')
// Throttle markdown rendering while the segment streams; render once at full
// fidelity the moment it completes.
const { html: renderedContent } = useStreamingMarkdown(
() => props.segment.text || '',
() => {
const text = props.segment.text || ''
return props.generatedFileNames?.size
? linkifyGeneratedFileUrls(text, props.generatedFileNames)
: text
},
() => isRunning.value,
)
</script>

View File

@ -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 }"
/>
</template>
@ -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')

View File

@ -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 = '<http://localhost:55793/api/v1/files/generated/ac38623b-7ed8-41f5-a80a-1e6761240ae0>'
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')
})
})

View File

@ -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<string, string> {
const names = new Map<string, string>()
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 (`<url>`) are left as-is.
*/
export function linkifyGeneratedFileUrls(text: string, names: Map<string, string>): 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})`
})
}