mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 11:37:31 +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)
|
* Replace fake {@code /api/v1/files/generated/{id}} URLs (cache-miss)
|
||||||
* with a user-visible warning. No-op when no cache is wired (legacy
|
* with a user-visible warning, and wrap live bare URLs into
|
||||||
* tests) or when the answer is empty.
|
* {@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) {
|
private String scrubFakeUrls(String text) {
|
||||||
if (generatedFileCache == null || text == null || text.isEmpty()) return 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) {
|
private FinishReason parseFinishReason(String reason) {
|
||||||
|
|||||||
@ -349,4 +349,49 @@ public class GeneratedFileCache {
|
|||||||
m.appendTail(out);
|
m.appendTail(out);
|
||||||
return out.toString();
|
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">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
|
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
|
||||||
|
import { linkifyGeneratedFileUrls } from '@/utils/generatedFileLinks'
|
||||||
import TypingCursor from './TypingCursor.vue'
|
import TypingCursor from './TypingCursor.vue'
|
||||||
import type { MessageSegment } from '@/types'
|
import type { MessageSegment } from '@/types'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
segment: MessageSegment
|
segment: MessageSegment
|
||||||
showCursor?: boolean
|
showCursor?: boolean
|
||||||
|
/** id → filename map for rewriting bare generated-file URLs into [name](url). */
|
||||||
|
generatedFileNames?: Map<string, string>
|
||||||
}>(), {
|
}>(), {
|
||||||
showCursor: false,
|
showCursor: false,
|
||||||
|
generatedFileNames: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isRunning = computed(() => props.segment.status === 'running')
|
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
|
// Throttle markdown rendering while the segment streams; render once at full
|
||||||
// fidelity the moment it completes.
|
// fidelity the moment it completes.
|
||||||
const { html: renderedContent } = useStreamingMarkdown(
|
const { html: renderedContent } = useStreamingMarkdown(
|
||||||
() => props.segment.text || '',
|
() => {
|
||||||
|
const text = props.segment.text || ''
|
||||||
|
return props.generatedFileNames?.size
|
||||||
|
? linkifyGeneratedFileUrls(text, props.generatedFileNames)
|
||||||
|
: text
|
||||||
|
},
|
||||||
() => isRunning.value,
|
() => isRunning.value,
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -75,6 +75,7 @@
|
|||||||
v-if="!seg.superseded || isSupersededExpanded(seg.id)"
|
v-if="!seg.superseded || isSupersededExpanded(seg.id)"
|
||||||
:segment="seg"
|
:segment="seg"
|
||||||
:show-cursor="showCursor && seg.status === 'running'"
|
:show-cursor="showCursor && seg.status === 'running'"
|
||||||
|
:generated-file-names="generatedFileNames"
|
||||||
:class="{ 'content-segment--superseded': seg.superseded }"
|
:class="{ 'content-segment--superseded': seg.superseded }"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@ -535,6 +536,7 @@ import {
|
|||||||
WarningFilled,
|
WarningFilled,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
|
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
|
||||||
|
import { buildGeneratedFileNameMap, linkifyGeneratedFileUrls } from '@/utils/generatedFileLinks'
|
||||||
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
|
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
|
||||||
import { useToolLabel } from '@/composables/useToolLabel'
|
import { useToolLabel } from '@/composables/useToolLabel'
|
||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
@ -729,9 +731,16 @@ const displayContent = computed(() => {
|
|||||||
if (text && isApprovalPlaceholder(text)) return ''
|
if (text && isApprovalPlaceholder(text)) return ''
|
||||||
// 有错误卡片时隐藏 [错误] 原始文本,避免重复展示
|
// 有错误卡片时隐藏 [错误] 原始文本,避免重复展示
|
||||||
if (status.value === 'failed' && errorInfo.value && text.startsWith('[错误]')) 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 ---
|
// --- parse_error detection ---
|
||||||
const parseErrorText = computed(() => {
|
const parseErrorText = computed(() => {
|
||||||
const errorPart = props.message.contentParts?.find(p => p.type === 'parse_error')
|
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