Newer
Older
navi-1 / webclient / src / components / artifacts / composables / useSessionLinks.js
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 }
}