diff --git a/webclient/src/components/artifacts/ArtifactsPanel.vue b/webclient/src/components/artifacts/ArtifactsPanel.vue index da10a91..a62a0a0 100644 --- a/webclient/src/components/artifacts/ArtifactsPanel.vue +++ b/webclient/src/components/artifacts/ArtifactsPanel.vue @@ -359,6 +359,10 @@ const artifacts = computed(() => chat.artifacts) const selected = computed(() => artifacts.value.find(item => artifactKey(item) === selectedKey.value) || artifacts.value[0] || null) +function normalizeUrlForDedup(url) { + return url.toLowerCase().replace(/[.,;:!?)\]}+]+$/, '') +} + const links = computed(() => { const seen = new Set() const out = [] @@ -373,8 +377,8 @@ // messages or across computed re-evaluations during streaming. const mdLinkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g while ((match = mdLinkRe.exec(text)) !== null) { - const url = match[2].trim() - const norm = url.toLowerCase() + let url = match[2].trim() + const norm = normalizeUrlForDedup(url) if (seen.has(norm)) continue seen.add(norm) out.push({ @@ -385,11 +389,14 @@ }) } - // Bare URLs — fresh regex per message for the same reason + // Bare URLs — fresh regex per message for the same reason. + // Trailing punctuation is stripped both from the extracted URL + // and from the dedup key so "https://a/b)" and "https://a/b" + // are treated as the same link. const bareUrlRe = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g while ((match = bareUrlRe.exec(text)) !== null) { - const url = match[0].trim() - const norm = url.toLowerCase() + let url = match[0].trim() + const norm = normalizeUrlForDedup(url) if (seen.has(norm)) continue // Rough guard: skip if this bare URL is the href part of a markdown link const before = text.slice(Math.max(0, match.index - 1), match.index) @@ -401,7 +408,7 @@ } seen.add(norm) out.push({ - url, + url: url.replace(/[.,;:!?)\]}+]+$/, ''), text: '', snippet: snippetOf(text, match.index), domain: domainOf(url),