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))