mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(chat): render generated-file download links with the file name, not the raw id URL (#466)
This commit is contained in:
parent
25737495e5
commit
bb946685b3
@ -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) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
56
mateclaw-ui/src/utils/__tests__/generatedFileLinks.test.ts
Normal file
56
mateclaw-ui/src/utils/__tests__/generatedFileLinks.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
44
mateclaw-ui/src/utils/generatedFileLinks.ts
Normal file
44
mateclaw-ui/src/utils/generatedFileLinks.ts
Normal 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})`
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user