import { computed } from 'vue'
/**
* Every link the assistant wrote in this session: markdown links first, then
* bare URLs. A markdown link wins over a bare URL for the same page and keeps
* its title; a bare URL that sits inside a markdown href is skipped.
*/
function stripTrailingPunctuation(url) {
return url.replace(/[.,;:!?)\]}+]+$/, '')
}
function normalizeUrlForDedup(url) {
try {
const cleaned = stripTrailingPunctuation(url)
const parsed = new URL(cleaned, window.location.origin)
const host = parsed.hostname.toLowerCase().replace(/^www\./, '')
let pathname = parsed.pathname.toLowerCase()
pathname = pathname.replace(/\/$/, '')
return `${host}${pathname}`
} catch {
return stripTrailingPunctuation(url).toLowerCase()
}
}
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
}
export function useSessionLinks(chat) {
const links = computed(() => {
const seen = new Set()
const out = []
// Ranges occupied by markdown link hrefs — bare URLs inside them are skipped
const protectedRanges = []
// Pass 1: Markdown links have priority — they carry a title / caption.
// We scan all messages first so a bare URL from a newer message never
// shadows a markdown link from an older one.
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
const mdLinkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g
while ((match = mdLinkRe.exec(text)) !== null) {
let url = match[2].trim()
const norm = normalizeUrlForDedup(url)
if (seen.has(norm)) continue
seen.add(norm)
out.push({
url,
text: match[1].trim(),
snippet: snippetOf(text, match.index),
domain: domainOf(url),
})
// Record the exact href range so bare-URL extraction skips it
const urlOffsetInMatch = match[0].lastIndexOf(match[2])
const urlStart = match.index + urlOffsetInMatch
protectedRanges.push({ start: urlStart, end: urlStart + match[2].length })
}
}
// Pass 2: Bare URLs — skip if already seen or inside a markdown link href.
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
const bareUrlRe = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g
while ((match = bareUrlRe.exec(text)) !== null) {
let url = match[0].trim()
// Strip trailing punctuation BEFORE dedup so "path." and "path" match
url = stripTrailingPunctuation(url)
const norm = normalizeUrlForDedup(url)
if (seen.has(norm)) continue
// Skip bare URLs that fall inside a markdown link href
const matchStart = match.index
const matchEnd = match.index + match[0].length
const isInsideMarkdown = protectedRanges.some(
r => matchStart >= r.start && matchEnd <= r.end
)
if (isInsideMarkdown) continue
seen.add(norm)
out.push({
url,
text: '',
snippet: snippetOf(text, match.index),
domain: domainOf(url),
})
}
}
return out
})
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')
}
}
return { links, copyAllLinks, openAllLinks }
}