From 5414060225ac72a64bf22b99e524105d2a52b7c5 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 2 Mar 2026 21:38:01 +0000 Subject: [PATCH] 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 --- .../app/src/components/chat/ChatMessage.vue | 11 +- .../src/components/content/MagazineGrid.vue | 13 +- .../app/src/composables/useContentPanel.ts | 112 +++++++++++++++--- 3 files changed, 117 insertions(+), 19 deletions(-) diff --git a/packages/app/src/components/chat/ChatMessage.vue b/packages/app/src/components/chat/ChatMessage.vue index 977b2d11..8131e0a9 100644 --- a/packages/app/src/components/chat/ChatMessage.vue +++ b/packages/app/src/components/chat/ChatMessage.vue @@ -95,6 +95,13 @@ > View all {{ inlineWebsitesLinks.length }} websites → + @@ -143,13 +150,15 @@ const inlineSongs = computed(() => inlineContent.value.songs) const inlinePodcasts = computed(() => inlineContent.value.podcasts) const inlineNewsLinks = computed(() => inlineContent.value.newsLinks ?? []) const inlineWebsitesLinks = computed(() => inlineContent.value.websitesLinks ?? []) +const inlineMagazineSections = computed(() => inlineContent.value.magazineSections ?? []) const hasContext = computed(() => !isUser.value && ( inlineFilms.value.length > 0 || inlineSongs.value.length > 0 || inlinePodcasts.value.length > 0 || inlineNewsLinks.value.length > 0 || - inlineWebsitesLinks.value.length > 0 + inlineWebsitesLinks.value.length > 0 || + inlineMagazineSections.value.length > 0 )) const displayText = computed(() => { diff --git a/packages/app/src/components/content/MagazineGrid.vue b/packages/app/src/components/content/MagazineGrid.vue index 95941fdd..f2e8c7ed 100644 --- a/packages/app/src/components/content/MagazineGrid.vue +++ b/packages/app/src/components/content/MagazineGrid.vue @@ -128,8 +128,11 @@ - -
+ +
{ 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 { - return text + const safe = text .replace(/&/g, '&') .replace(//g, '>') .replace(/\*\*([^*]+)\*\*/g, '$1') + const withParas = safe.replace(/\n\n+/g, '

').replace(/\n/g, '
') + return `

${withParas}

` } diff --git a/packages/app/src/composables/useContentPanel.ts b/packages/app/src/composables/useContentPanel.ts index f3e26fb0..b2d7a4c4 100644 --- a/packages/app/src/composables/useContentPanel.ts +++ b/packages/app/src/composables/useContentPanel.ts @@ -95,22 +95,106 @@ function extractFirstImageFromText(text: string): string | 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, +): 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[] { const sections: MagazineSection[] = [] - const re = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*[:\u2014\u2013–]\s*(.+?)(?=\n\s*[-•]\s*\*\*|\n\s*[-•]\s*[^*]|\n\n##\s|$)/gms - let match: RegExpExecArray | null - while ((match = re.exec(text)) !== null) { - const title = match[1].trim().slice(0, 120) - const raw = match[2].trim().replace(/\n+/g, ' ') - const content = raw.slice(0, 800) - if (title.length > 1 && content.length > 10) { - const url = extractUrlFromText(raw) - const author = extractAuthorFromText(raw) - const imageUrl = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(raw)?.[1] - sections.push({ title, content, url, author, imageUrl }) + const seen = new Set() + const blockContents = new Set() // avoid duplicate bullets already in ## blocks + + // 1. ## Heading blocks: full content until next ## or **Section** + const headingRe = /^##\s*[^\n]*?(?:⚡|🔥|📌|✨)?\s*(.+?)\n\n([\s\S]+?)(?=\n##\s|\n\*\*[^*]+\*\*[🟠🔴🟢]?|\n\nFor deeper|\n\nThis is being|\z)/gim + let m: RegExpExecArray | null + while ((m = headingRe.exec(text)) !== null) { + const title = m[1].trim().replace(/^[⚡🔥📌✨]\s*/, '').split(':')[0].trim() + const content = m[2].trim() + if (content.length > 20) { + 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 } @@ -529,7 +613,7 @@ export function useContentPanel() { // Magazine = bullet-style sections (- **Title**: Content) 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. const newsContext = isNewsQuery(userQuery) || isNewsLikeResponse(text) @@ -620,7 +704,7 @@ export function useContentPanel() { const songs = extractAllSongs(text) const podcasts = extractAllPodcasts(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 boldDomains = extractBoldDomainLinks(text) const hasNews = webResults.length > 0 && (isNewsQuery(userQuery) || isNewsLikeResponse(text))