diff --git a/webclient/src/components/artifacts/ArtifactsPanel.vue b/webclient/src/components/artifacts/ArtifactsPanel.vue index b24dbe5..cc93c5c 100644 --- a/webclient/src/components/artifacts/ArtifactsPanel.vue +++ b/webclient/src/components/artifacts/ArtifactsPanel.vue @@ -14,20 +14,37 @@ />
-
-
Artifacts
-
{{ artifacts.length }} published
+
+ +
-
- - No published files -
+ @@ -130,6 +188,7 @@ const sourceError = ref('') const isResizing = ref(false) const viewportWidth = ref(typeof window === 'undefined' ? 0 : window.innerWidth) +const activeTab = ref('artifacts') const DRAWER_DESKTOP_BREAKPOINT = 980 const CHAT_MIN_WIDTH = 500 @@ -139,6 +198,57 @@ const artifacts = computed(() => chat.artifacts) const selected = computed(() => artifacts.value.find(item => artifactKey(item) === selectedKey.value) || artifacts.value[0] || null) + +const links = computed(() => { + const seen = new Set() + const out = [] + const mdLinkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g + const bareUrlRe = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g + + for (let i = chat.messages.length - 1; i >= 0; i--) { + const m = chat.messages[i] + if (m.role !== 'assistant') continue + const text = m.text || '' + let match + + // Markdown links + while ((match = mdLinkRe.exec(text)) !== null) { + const url = match[2].trim() + const norm = url.toLowerCase() + if (seen.has(norm)) continue + seen.add(norm) + out.push({ + url, + text: match[1].trim(), + snippet: snippetOf(text, match.index), + domain: domainOf(url), + }) + } + + // Bare URLs (skip those inside markdown links we already matched) + while ((match = bareUrlRe.exec(text)) !== null) { + const url = match[0].trim() + const norm = url.toLowerCase() + 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) + if (before === '(' && text[match.index + match[0].length] === ')') { + const openBracket = text.lastIndexOf('[', match.index - 2) + if (openBracket !== -1 && text.slice(openBracket + 1, match.index - 2).indexOf(']') === -1) { + continue + } + } + seen.add(norm) + out.push({ + url, + text: '', + snippet: snippetOf(text, match.index), + domain: domainOf(url), + }) + } + } + return out +}) const selectedSourceLanguage = computed(() => selected.value ? sourceLanguage(selected.value) : 'plaintext') const highlightedSource = computed(() => highlightSource(sourceText.value, selectedSourceLanguage.value)) const isDesktop = computed(() => viewportWidth.value > DRAWER_DESKTOP_BREAKPOINT) @@ -355,6 +465,35 @@ function copyIcon(item) { return copiedKey.value === artifactKey(item) ? 'ph ph-check' : 'ph ph-copy' } + +function domainOf(url) { + try { + return new URL(url).hostname.replace(/^www\./, '') + } catch { + return url.split('/')[2] || url + } +} + +function snippetOf(text, index, maxLen = 60) { + const start = Math.max(0, index - maxLen) + const end = Math.min(text.length, index + maxLen) + let s = text.slice(start, end).replace(/\s+/g, ' ').trim() + if (start > 0) s = '…' + s + if (end < text.length) s = s + '…' + return s +} + +function copyAllLinks() { + const lines = links.value.map(l => l.url) + if (!lines.length) return + navigator.clipboard.writeText(lines.join('\n')).catch(() => {}) +} + +function openAllLinks() { + for (const l of links.value.slice(0, 8)) { + window.open(l.url, '_blank', 'noopener,noreferrer') + } +}