feat(chat): add support for inline magazine sections and enhance content extraction
- Updated ChatMessage component to display a button for viewing brief magazine sections when available. - Enhanced useContentPanel to extract and manage magazine sections, improving content organization. - Refactored extractMagazineSections function to comprehensively handle various content formats, including headings and bullet points. - Improved formatting of content to preserve bold text and newlines as paragraphs. Made-with: Cursor
This commit is contained in:
@@ -95,6 +95,13 @@
|
|||||||
>
|
>
|
||||||
View all {{ inlineWebsitesLinks.length }} websites →
|
View all {{ inlineWebsitesLinks.length }} websites →
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-else-if="inlineMagazineSections.length > 0"
|
||||||
|
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
|
||||||
|
@click.stop="openPanel"
|
||||||
|
>
|
||||||
|
View brief →
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,13 +150,15 @@ const inlineSongs = computed(() => inlineContent.value.songs)
|
|||||||
const inlinePodcasts = computed(() => inlineContent.value.podcasts)
|
const inlinePodcasts = computed(() => inlineContent.value.podcasts)
|
||||||
const inlineNewsLinks = computed(() => inlineContent.value.newsLinks ?? [])
|
const inlineNewsLinks = computed(() => inlineContent.value.newsLinks ?? [])
|
||||||
const inlineWebsitesLinks = computed(() => inlineContent.value.websitesLinks ?? [])
|
const inlineWebsitesLinks = computed(() => inlineContent.value.websitesLinks ?? [])
|
||||||
|
const inlineMagazineSections = computed(() => inlineContent.value.magazineSections ?? [])
|
||||||
|
|
||||||
const hasContext = computed(() => !isUser.value && (
|
const hasContext = computed(() => !isUser.value && (
|
||||||
inlineFilms.value.length > 0 ||
|
inlineFilms.value.length > 0 ||
|
||||||
inlineSongs.value.length > 0 ||
|
inlineSongs.value.length > 0 ||
|
||||||
inlinePodcasts.value.length > 0 ||
|
inlinePodcasts.value.length > 0 ||
|
||||||
inlineNewsLinks.value.length > 0 ||
|
inlineNewsLinks.value.length > 0 ||
|
||||||
inlineWebsitesLinks.value.length > 0
|
inlineWebsitesLinks.value.length > 0 ||
|
||||||
|
inlineMagazineSections.value.length > 0
|
||||||
))
|
))
|
||||||
|
|
||||||
const displayText = computed(() => {
|
const displayText = computed(() => {
|
||||||
|
|||||||
@@ -128,8 +128,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<!-- Secondary stories grid -->
|
<!-- Secondary stories grid (single col when many sections for readability) -->
|
||||||
<div class="grid sm:grid-cols-2 gap-4">
|
<div
|
||||||
|
class="gap-4"
|
||||||
|
:class="sections.length > 4 ? 'flex flex-col' : 'grid sm:grid-cols-2'"
|
||||||
|
>
|
||||||
<article
|
<article
|
||||||
v-for="(section, i) in sections.slice(1)"
|
v-for="(section, i) in sections.slice(1)"
|
||||||
:key="i"
|
:key="i"
|
||||||
@@ -278,12 +281,14 @@ const headlineText = computed(() => {
|
|||||||
return q.length > 100 ? q.slice(0, 97) + '…' : q
|
return q.length > 100 ? q.slice(0, 97) + '…' : q
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Render content with **bold** preserved (sanitized). */
|
/** Render content with **bold** preserved and newlines as paragraphs (sanitized). */
|
||||||
function formatContent(text: string): string {
|
function formatContent(text: string): string {
|
||||||
return text
|
const safe = text
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
|
const withParas = safe.replace(/\n\n+/g, '</p><p class="mt-2">').replace(/\n/g, '<br/>')
|
||||||
|
return `<p>${withParas}</p>`
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -95,22 +95,106 @@ function extractFirstImageFromText(text: string): string | undefined {
|
|||||||
return ext ? ext[1] : undefined
|
return ext ? ext[1] : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract magazine-style bullets: - **Title**: Content or - **Title** — Content */
|
const MAGAZINE_CONTENT_MAX = 2000
|
||||||
|
|
||||||
|
function addSection(
|
||||||
|
sections: MagazineSection[],
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
seen: Set<string>,
|
||||||
|
): void {
|
||||||
|
const t = title.trim().replace(/\s+/g, ' ').slice(0, 150)
|
||||||
|
const c = content.trim().replace(/\n{2,}/g, '\n\n').slice(0, MAGAZINE_CONTENT_MAX)
|
||||||
|
if (t.length < 2 || c.length < 15) return
|
||||||
|
const key = `${t.slice(0, 50)}`
|
||||||
|
if (seen.has(key)) return
|
||||||
|
seen.add(key)
|
||||||
|
sections.push({
|
||||||
|
title: t,
|
||||||
|
content: c,
|
||||||
|
url: extractUrlFromText(content),
|
||||||
|
author: extractAuthorFromText(content),
|
||||||
|
imageUrl: /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(content)?.[1],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract magazine sections comprehensively: ## headings, **camp** blocks, bullets, intro */
|
||||||
function extractMagazineSections(text: string): MagazineSection[] {
|
function extractMagazineSections(text: string): MagazineSection[] {
|
||||||
const sections: MagazineSection[] = []
|
const sections: MagazineSection[] = []
|
||||||
const re = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*[:\u2014\u2013–]\s*(.+?)(?=\n\s*[-•]\s*\*\*|\n\s*[-•]\s*[^*]|\n\n##\s|$)/gms
|
const seen = new Set<string>()
|
||||||
let match: RegExpExecArray | null
|
const blockContents = new Set<string>() // avoid duplicate bullets already in ## blocks
|
||||||
while ((match = re.exec(text)) !== null) {
|
|
||||||
const title = match[1].trim().slice(0, 120)
|
// 1. ## Heading blocks: full content until next ## or **Section**
|
||||||
const raw = match[2].trim().replace(/\n+/g, ' ')
|
const headingRe = /^##\s*[^\n]*?(?:⚡|🔥|📌|✨)?\s*(.+?)\n\n([\s\S]+?)(?=\n##\s|\n\*\*[^*]+\*\*[🟠🔴🟢]?|\n\nFor deeper|\n\nThis is being|\z)/gim
|
||||||
const content = raw.slice(0, 800)
|
let m: RegExpExecArray | null
|
||||||
if (title.length > 1 && content.length > 10) {
|
while ((m = headingRe.exec(text)) !== null) {
|
||||||
const url = extractUrlFromText(raw)
|
const title = m[1].trim().replace(/^[⚡🔥📌✨]\s*/, '').split(':')[0].trim()
|
||||||
const author = extractAuthorFromText(raw)
|
const content = m[2].trim()
|
||||||
const imageUrl = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(raw)?.[1]
|
if (content.length > 20) {
|
||||||
sections.push({ title, content, url, author, imageUrl })
|
blockContents.add(content)
|
||||||
|
addSection(sections, title, content, seen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. **Pro/ Anti camp** blocks with their bullets
|
||||||
|
const campRe = /\*\*([^*]+(?:camp| side| view)[^*]*)\*\*[🟠🔴🟢]?\s*\n([\s\S]+?)(?=\n\*\*[^*]+(?:camp| side)[^*]*\*\*|\n##\s|\n\nThis is|\n\nFor deeper|\z)/gim
|
||||||
|
while ((m = campRe.exec(text)) !== null) {
|
||||||
|
const title = m[1].trim()
|
||||||
|
const content = m[2].trim()
|
||||||
|
if (content.length > 15) addSection(sections, title, content, seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Bullets: - **Title**: Content (skip if already in a ## block)
|
||||||
|
const bulletRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*[:\u2014\u2013–]\s*(.+?)(?=\n\s*[-•]\s*\*\*|\n\s*[-•]\s+\w|\n\n##\s|\n\*\*[^*]+\*\*[🟠🔴]?|\z)/gms
|
||||||
|
while ((m = bulletRe.exec(text)) !== null) {
|
||||||
|
const raw = m[2].trim().replace(/\n+/g, ' ')
|
||||||
|
if (raw.length < 15) continue
|
||||||
|
const inBlock = [...blockContents].some((b) => b.includes(raw.slice(0, 100)))
|
||||||
|
if (!inBlock) addSection(sections, m[1].trim(), raw, seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. - **Name** (Role) description — attributed quotes
|
||||||
|
const attrRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*\(([^)]+)\)\s+([^-\n].+?)(?=\n\s*[-•]|\n\*\*|\n##\s|\z)/gms
|
||||||
|
while ((m = attrRe.exec(text)) !== null) {
|
||||||
|
const content = m[3].trim().replace(/\n+/g, ' ')
|
||||||
|
if (content.length > 20) addSection(sections, `${m[1].trim()} (${m[2].trim()})`, content, seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Intro paragraph before first ## or ---
|
||||||
|
const introMatch = /^([\s\S]+?)(?=\n##\s|\n---\s*\n|\n-\s+\*\*[^*]+\*\*\s*[:\u2014])/m.exec(text)
|
||||||
|
if (introMatch) {
|
||||||
|
const intro = introMatch[1].trim().replace(/^[#*_\s-]+/gm, '').trim()
|
||||||
|
if (intro.length > 60 && !seen.has('Summary')) {
|
||||||
|
addSection(sections, 'Summary', intro, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. "This is being called..." / closing paragraph
|
||||||
|
const closingMatch = /(This is being called[^.]+\.[^"]*"[^"]+"[^.]*\.)/i.exec(text)
|
||||||
|
if (closingMatch && !seen.has('Key')) {
|
||||||
|
addSection(sections, 'Key takeaway', closingMatch[1].trim(), seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. "For deeper analysis" / podcast or further reading block
|
||||||
|
const deeperMatch = /(?:For deeper analysis[^:]*:[\s\S]+?)(?=\n\z)/im.exec(text)
|
||||||
|
if (deeperMatch) {
|
||||||
|
const block = deeperMatch[1].trim()
|
||||||
|
if (block.length > 30 && !seen.has('Further')) {
|
||||||
|
addSection(sections, 'Further reading', block, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve document order: Summary first, then ## blocks order, then camps, then key/further
|
||||||
|
const order = ['Summary', 'Key takeaway', 'Further reading']
|
||||||
|
sections.sort((a, b) => {
|
||||||
|
const ai = order.indexOf(a.title)
|
||||||
|
const bi = order.indexOf(b.title)
|
||||||
|
if (ai >= 0 && bi >= 0) return ai - bi
|
||||||
|
if (ai >= 0) return -1
|
||||||
|
if (bi >= 0) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
return sections
|
return sections
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,7 +613,7 @@ export function useContentPanel() {
|
|||||||
|
|
||||||
// Magazine = bullet-style sections (- **Title**: Content)
|
// Magazine = bullet-style sections (- **Title**: Content)
|
||||||
const magazineSections = extractMagazineSections(text)
|
const magazineSections = extractMagazineSections(text)
|
||||||
const hasMagazine = magazineSections.length >= 2 && (isNewsQuery(userQuery) || isNewsLikeResponse(text) || /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening/i.test(text))
|
const hasMagazine = magazineSections.length >= 1 && (isNewsQuery(userQuery) || isNewsLikeResponse(text) || /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening/i.test(text))
|
||||||
|
|
||||||
// News = actual articles (web search + RSS from website domains). Plain links → Websites.
|
// News = actual articles (web search + RSS from website domains). Plain links → Websites.
|
||||||
const newsContext = isNewsQuery(userQuery) || isNewsLikeResponse(text)
|
const newsContext = isNewsQuery(userQuery) || isNewsLikeResponse(text)
|
||||||
@@ -620,7 +704,7 @@ export function useContentPanel() {
|
|||||||
const songs = extractAllSongs(text)
|
const songs = extractAllSongs(text)
|
||||||
const podcasts = extractAllPodcasts(text)
|
const podcasts = extractAllPodcasts(text)
|
||||||
const magazineSections = extractMagazineSections(text)
|
const magazineSections = extractMagazineSections(text)
|
||||||
const hasMagazine = magazineSections.length >= 2 && (isNewsQuery(userQuery) || isNewsLikeResponse(text) || /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening/i.test(text))
|
const hasMagazine = magazineSections.length >= 1 && (isNewsQuery(userQuery) || isNewsLikeResponse(text) || /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening/i.test(text))
|
||||||
const fromMarkdown = extractMarkdownLinks(text)
|
const fromMarkdown = extractMarkdownLinks(text)
|
||||||
const boldDomains = extractBoldDomainLinks(text)
|
const boldDomains = extractBoldDomainLinks(text)
|
||||||
const hasNews = webResults.length > 0 && (isNewsQuery(userQuery) || isNewsLikeResponse(text))
|
const hasNews = webResults.length > 0 && (isNewsQuery(userQuery) || isNewsLikeResponse(text))
|
||||||
|
|||||||
Reference in New Issue
Block a user