feat(content): comprehensive extraction hardening, code mode UI, overnight plan

- Relax isBookLikeResponse threshold (>=2 to >=1)
- Widen book patterns: inline prose, verb-preceded, numbered bold, em-dash
- Remove overly aggressive film/song gating on book extraction
- Allow songs to coexist with film/book tags (explicit tags always returned)
- Strengthen TV patterns: seasons, created by, standalone "tv" query match
- Fix TV_EXT_RE to handle both Title|Year|Creator and Title|Creator|Year
- Widen place patterns: type word search in descriptions, bold fallback
- Add "pizza" to isPlaceQuery and preferredFirstTab
- Add Code tab: isCodeQuery, isCodeLikeResponse, extractCodeBlocks, wiring
- Fix image threshold: single image with meaningful alt text shown
- Refactor filterTabsByContext: specialized paths now append remaining content
- Add code mode UI: orange input styling, design system selection, file selection
- DesignSystemGrid: selection toggle only on checkmark, card click opens detail
- Add 64-test extraction quality test suite
- Update overnight plan.md and prompt.md for hardening run

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 10:54:03 +00:00
co-authored by Claude Opus 4.6
parent 75e9274582
commit f0eb4383bd
13 changed files with 1049 additions and 260 deletions
+127 -20
View File
@@ -16,7 +16,7 @@ export const PODCAST_TAG_RE = /\[\[podcast:(p?\d+)\]\]/gi
export const PODCAST_EXT_RE = /\[\[podcast_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
export const BOOK_TAG_RE = /\[\[book:(b?\d+)\]\]/gi
export const BOOK_EXT_RE = /\[\[book_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
export const TV_EXT_RE = /\[\[tv_ext:([^|]+)\|([^|]*)(?:\|(\d{4}))?\]\]/gi
export const TV_EXT_RE = /\[\[tv_ext:([^|]+)\|([^|\]]+)(?:\|([^|\]]+))?\]\]/gi
export const PLACE_EXT_RE = /\[\[place_ext:([^|]+)\|([^|]*)(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?\]\]/gi
export const MARKDOWN_LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g
const SAFE_URL_SCHEME = /^https?:\/\//i
@@ -602,12 +602,25 @@ function extractSongsFromPatterns(text: string): Song[] {
export function extractAllSongs(text: string, userQuery = ''): Song[] {
const librarySongs = resolveSongs(extractSongIds(text))
const externalSongs = extractExternalSongs(text)
if (librarySongs.length > 0 || externalSongs.length > 0) {
return [...librarySongs, ...externalSongs]
// Explicit song tags and library matches always returned
const explicitSongs = [...librarySongs, ...externalSongs]
// Skip pattern-based fallback when conflicting content tags are present
const hasConflictingTags = extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text) ||
extractPodcastIds(text).length > 0 || /\[\[podcast_ext:/.test(text) ||
/\[\[tv_ext:/.test(text) || /\[\[book_ext:/.test(text)
if (explicitSongs.length > 0) {
if (hasConflictingTags) return explicitSongs
// Also grab pattern matches alongside explicit songs
const libMatches = extractSongsFromLibraryMatch(text)
const patternMatches = extractSongsFromPatterns(text)
const existingKeys = new Set(explicitSongs.map((s) => `${s.title.toLowerCase()}|${s.artist.toLowerCase()}`))
const extras = [...libMatches, ...patternMatches].filter(
(p) => !existingKeys.has(`${p.title.toLowerCase()}|${p.artist.toLowerCase()}`)
)
return [...explicitSongs, ...extras]
}
if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) return []
if (extractPodcastIds(text).length > 0 || /\[\[podcast_ext:/.test(text)) return []
if (/\[\[tv_ext:/.test(text) || /\[\[book_ext:/.test(text)) return []
if (hasConflictingTags) return []
if (isNewsLikeResponse(text)) return []
const q = userQuery.toLowerCase()
if (q && !isMusicQuery(q)) return []
@@ -723,9 +736,20 @@ function extractBooksFromPatterns(text: string): Book[] {
const seen = new Set<string>()
const patterns: { re: RegExp; titleIdx: number; authorIdx: number }[] = [
{ re: /["""]([^"""]{2,80})["""]\s+by\s+([A-Z][^,\n.]{1,50}?)(?:\s*[,()\n.]|$)/gi, titleIdx: 1, authorIdx: 2 },
{ re: /\*\*([^*]{2,80})\*\*\s+(?:by|—|)\s+\*?([A-Z][^*\n]{1,50}?)\*?(?:\s*[,()*\n]|$)/g, titleIdx: 1, authorIdx: 2 },
{ re: /(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n\-–—]{2,80}?)\*{0,2}\s+(?:by|—|)\s+\*?([A-Z][^*\n]{1,50}?)\*?(?:\s*[,()*\n]|$)/gm, titleIdx: 1, authorIdx: 2 },
// "Title" by Author or "Title" by Author (straight and smart quotes)
{ re: /[""\u201C\u201D]([^"""\u201C\u201D]{2,80})[""\u201C\u201D]\s+by\s+([A-Z][^,\n]{1,50}?)(?:\s+(?:is|was|has|—||-)|[,()\n.]|$)/gi, titleIdx: 1, authorIdx: 2 },
// **Title** by/—/ Author
{ re: /\*\*([^*]{2,80})\*\*\s+(?:by|—|)\s+\*?([A-Z][^*\n]{1,50}?)\*?(?:\s+(?:is|was|has|—||-)|[,()*\n.]|$)/g, titleIdx: 1, authorIdx: 2 },
// List item: "1. Title by Author" or "- Title by Author"
{ re: /(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n\-–—]{2,80}?)\*{0,2}\s+(?:by|—|)\s+\*?([A-Z][^*\n]{1,50}?)\*?(?:\s+(?:is|was|has|—||-)|[,()*\n.]|$)/gm, titleIdx: 1, authorIdx: 2 },
// Numbered list: "1. **Title** by Author — Description"
{ re: /(?:^|\n)\s*\d+\.\s*\*\*([^*]{2,80})\*\*\s+by\s+([A-Z][^,\n(—–-]{1,50}?)(?:\s*[,()\n—–-]|$)/gm, titleIdx: 1, authorIdx: 2 },
// Em-dash list: "- Title — Author (year)" or "- Title — Author"
{ re: /(?:^|\n)\s*[-•]\s+([^—–\n]{2,80}?)\s+[—–]\s+([A-Z][^,\n(]{1,50}?)(?:\s*\(\d{4}\))?(?:\s*[,\n]|$)/gm, titleIdx: 1, authorIdx: 2 },
// Inline prose at line/sentence start: "Title Words by Author"
{ re: /(?:^|\n|[.!?]\s+)([A-Z][a-z]+(?:\s+[A-Z][a-z]*){1,8})\s+by\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]*){0,4})(?:\s+[a-z]|[,.()\n—–-]|$)/gm, titleIdx: 1, authorIdx: 2 },
// After recommendation verbs: "enjoy Title by Author"
{ re: /(?:read|enjoy|recommend|check out|try|start with|pick up)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]*){1,8})\s+by\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]*){0,4})(?:\s+[a-z]|[,.()\n—–-]|$)/gi, titleIdx: 1, authorIdx: 2 },
]
for (const { re, titleIdx, authorIdx } of patterns) {
@@ -764,10 +788,6 @@ export function extractAllBooks(text: string, userQuery: string): Book[] {
const externalBooks = extractExternalBooks(text)
if (externalBooks.length > 0) return externalBooks
if (!isBookQuery(userQuery) && !isBookLikeResponse(text)) return []
if (!isBookQuery(userQuery)) {
if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) return []
if (extractSongIds(text).length > 0 || /\[\[song_ext:/.test(text)) return []
}
if (isNewsLikeResponse(text)) return []
const cleanText = text.replace(/\n---\n\s*(?:Sources|References|Links):?\s*\n[\s\S]*$/i, '')
return extractBooksFromPatterns(cleanText)
@@ -782,8 +802,13 @@ function extractExternalTVSeries(text: string): TVSeries[] {
const re = new RegExp(TV_EXT_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const title = match[1].trim()
const creator = match[2].trim()
const year = match[3] ? parseInt(match[3], 10) : undefined
const field2 = match[2].trim()
const field3 = match[3]?.trim()
// Handle both Title|Year|Creator and Title|Creator|Year formats
const isField2Year = /^\d{4}$/.test(field2)
const year = isField2Year ? parseInt(field2, 10)
: field3 && /^\d{4}$/.test(field3) ? parseInt(field3, 10) : undefined
const creator = isField2Year ? (field3 || undefined) : (field2 || undefined)
const key = title.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
@@ -801,8 +826,18 @@ function extractExternalTVSeries(text: string): TVSeries[] {
}
function isTVLikeResponse(text: string): boolean {
return /\b(season|episodes?|showrunner|streaming|renewed|cancelled|premiere|network|HBO|Netflix|AMC|FX|Apple TV|Disney\+)\b/i.test(text) &&
(text.match(/\bseason\b/gi)?.length ?? 0) >= 2
const hasKeyword = /\b(season|episodes?|showrunner|streaming|renewed|cancelled|premiere|network|HBO|Netflix|AMC|FX|Apple TV|Disney\+|created by)\b/i.test(text)
if (!hasKeyword) return false
// Single "season" mention is sufficient
if ((text.match(/\bseason\b/gi)?.length ?? 0) >= 1) return true
// Multiple distinct TV signals without requiring "season"
const tvSignals = [
/\bepisodes?\b/i, /\bshowrunner\b/i, /\bstreaming\b/i, /\brenewed\b/i,
/\bcancelled\b/i, /\bpremiere\b/i, /\bnetwork\b/i,
/\b(HBO|Netflix|AMC|FX|Apple TV|Disney\+|Hulu|Amazon Prime)\b/i,
/\bseries\b/i, /\bpilot\b/i, /\bminiseries\b/i, /\bcreated by\b/i,
]
return tvSignals.filter(re => re.test(text)).length >= 2
}
function extractTVSeriesFromPatterns(text: string): TVSeries[] {
@@ -812,6 +847,10 @@ function extractTVSeriesFromPatterns(text: string): TVSeries[] {
/"([^"]{2,60})"\s*[-–—]\s*(?:a |an )?(?:series|show|tv)/gi,
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*(?:a |an )?(?:\w+ )?(?:series|show|drama|comedy|thriller|animated)/gi,
/(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n]{2,60}?)\*{0,2}\s*\((\d{4})(?:[-]\d{0,4})?(?:,\s*\d+ seasons?)?\)/gm,
// Bold title with seasons info: **Title** — 5 seasons
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*\d+\s*seasons?/gi,
// "Title" — creator (in TV context)
/"([^"]{2,60})"\s*[-–—]\s*([A-Z][^,\n.]{1,50}?)(?:\s*[,()\n]|$)/gi,
]
for (const re of patterns) {
let m: RegExpExecArray | null
@@ -918,6 +957,8 @@ export function extractAllImages(text: string, userQuery: string): ImageItem[] {
const images = extractImages(text)
if (images.length === 0) return []
if (isImageQuery(userQuery) || images.length >= 2) return images
// Show single image if it has meaningful alt text (intentional image context)
if (images.length === 1 && images[0].alt && images[0].alt.length > 2) return images
return []
}
@@ -957,9 +998,16 @@ function extractPlacesFromPatterns(text: string): Place[] {
const places: { name: string; cuisine?: string; city?: string; rating?: number; priceLevel?: number; desc: string; pos: number }[] = []
const seen = new Set<string>()
const placeTypeWords = 'restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|eatery|spot|joint|trattoria|taqueria|brasserie|cantina|chophouse|creamery|diner|tavern'
const patterns: RegExp[] = [
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*(?:a |an )?(?:(\w[\w\s]{1,30}?)\s+)?(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|eatery|spot|joint)/gi,
/(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n]{2,60}?)\*{0,2}\s*[-–—(]\s*(?:(\w[\w\s&]{1,30}?)\s+)?(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|cuisine|food|dining)/gim,
// **Name** — ... type word (within 80 chars of dash)
new RegExp(`\\*\\*([^*]{2,60})\\*\\*\\s*[-–—:]\\s*(?:a |an )?(?:(\\w[\\w\\s]{1,30}?)\\s+)?(?:${placeTypeWords})`, 'gi'),
// List item with type word
new RegExp(`(?:^|\\n)\\s*(?:\\d+\\.\\s*|[-•]\\s*)\\*{0,2}([^*\\n]{2,60}?)\\*{0,2}\\s*[-–—(]\\s*(?:(\\w[\\w\\s&]{1,30}?)\\s+)?(?:${placeTypeWords}|cuisine|food|dining)`, 'gim'),
// **Name** — description containing a place type word anywhere in the next 120 chars
new RegExp(`\\*\\*([^*]{2,60})\\*\\*\\s*[-–—:]\\s*([^\\n]{3,120}?\\b(?:${placeTypeWords})\\b[^\\n]{0,40})`, 'gi'),
// List: 1. **Name** — description with place type word
new RegExp(`(?:^|\\n)\\s*(?:\\d+\\.\\s*|[-•]\\s*)\\*\\*([^*]{2,60})\\*\\*\\s*[-–—:]\\s*([^\\n]{3,120}?\\b(?:${placeTypeWords})\\b[^\\n]{0,40})`, 'gim'),
]
for (const re of patterns) {
@@ -996,12 +1044,71 @@ function extractPlacesFromPatterns(text: string): Place[] {
}))
}
function extractPlacesFromBoldPatterns(text: string): Place[] {
const places: { name: string; desc: string; pos: number }[] = []
const seen = new Set<string>()
// Match any bold title followed by dash/colon and description (for place-query context)
const re = /(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*\*([^*]{2,60})\*\*\s*[-–—:]\s*([^\n]{5,200})/gm
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const name = m[1].trim()
if (name.length < 2) continue
if (/\[\[(film|song|podcast|book|tv|place)(_ext)?:/.test(name)) continue
const key = name.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
places.push({ name, desc: m[2].trim(), pos: m.index })
}
return places
.sort((a, b) => a.pos - b.pos)
.map(({ name, desc }) => ({
id: `ext-place-${name.toLowerCase().replace(/\W/g, '-')}`,
name,
description: desc,
sources: [],
}))
}
export function extractAllPlaces(text: string, userQuery: string): Place[] {
const external = extractExternalPlaces(text)
if (external.length > 0) return external
if (!isPlaceQuery(userQuery) && !isPlaceLikeResponse(text)) return []
if (isNewsLikeResponse(text)) return []
return extractPlacesFromPatterns(text)
const fromPatterns = extractPlacesFromPatterns(text)
// For place queries, supplement with bold-title patterns to catch items without type words
if (isPlaceQuery(userQuery)) {
const fromBold = extractPlacesFromBoldPatterns(text)
const seenNames = new Set(fromPatterns.map(p => p.name.toLowerCase()))
const extras = fromBold.filter(p => !seenNames.has(p.name.toLowerCase()))
const combined = [...fromPatterns, ...extras]
return combined.length > 0 ? combined : []
}
return fromPatterns
}
// ─── Code block extraction ─────────────────────────────────────────
export interface CodeBlock {
language: string
code: string
label?: string
}
export function extractCodeBlocks(text: string): CodeBlock[] {
const blocks: CodeBlock[] = []
const re = /```(\w*)\n([\s\S]*?)```/g
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const language = m[1] || 'text'
const code = m[2].trimEnd()
if (code.length < 3) continue
// Try to find a label from the line above the code block
const before = text.slice(Math.max(0, m.index - 200), m.index)
const labelMatch = /(?:^|\n)\s*(?:\*\*([^*]{2,80})\*\*|#+\s+(.{2,80}))\s*\n?\s*$/.exec(before)
const label = labelMatch?.[1] || labelMatch?.[2] || undefined
blocks.push({ language, code, label })
}
return blocks
}
// ─── Tag stripping ────────────────────────────────────────────────
@@ -59,12 +59,12 @@ export function isBookQuery(q: string): boolean {
export function isBookLikeResponse(text: string): boolean {
return /\b(novel|author|pages?|ISBN|published|bestsell|literary|fiction|nonfiction|book)\b/i.test(text) &&
(text.match(/\bby\s+[A-Z]/g)?.length ?? 0) >= 2
(text.match(/\bby\s+[A-Z]/g)?.length ?? 0) >= 1
}
export function isTVQuery(q: string): boolean {
return /\b(tv show|tv series|series|television|streaming|binge|watch|recommend.*show|best show|season|netflix|hbo|hulu|disney\+?|apple tv|amazon prime|peacock|paramount\+?|showtime|miniseries|docuseries|sitcom|drama series|limited series|pilot|showrunner|renewed|cancelled|premiere)\b/i.test(q) ||
/what'?s good on|anything to (binge|watch)|what should (i|we) (watch|stream)|good (show|series) to|new (show|series)|best (show|series)|recommend.*(show|series|watch)/i.test(q)
return /\b(tv\b|tv shows?|tv series|series|television|streaming|binge|watch|recommend.*shows?|best shows?|seasons?|netflix|hbo|hulu|disney\+?|apple tv|amazon prime|peacock|paramount\+?|showtime|miniseries|docuseries|sitcom|drama series|limited series|pilot|showrunner|renewed|cancelled|premiere)\b/i.test(q) ||
/what'?s good on|anything to (binge|watch)|what should (i|we) (watch|stream)|good (shows?|series) to|new (shows?|series)|best (shows?|series)|recommend.*(shows?|series|watch)/i.test(q)
}
export function isImageQuery(q: string): boolean {
@@ -72,7 +72,7 @@ export function isImageQuery(q: string): boolean {
}
export function isPlaceQuery(q: string): boolean {
return /\b(restaurant|restaurants|place|places|food|eat|eating|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|where to eat|good food|best food|where should i eat|recommend.*eat|recommend.*restaurant|recommend.*place|hungry|starving|takeout|take-?out|delivery|reservation|reservations|michelin|yelp|zagat|foodie|gastropub|tapas|dim sum|bbq|barbecue|food truck|brewery|winery|cocktail bar|speakeasy|rooftop bar|happy hour)\b/i.test(q) ||
return /\b(restaurant|restaurants|place|places|food|eat|eating|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizza|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|where to eat|good food|best food|where should i eat|recommend.*eat|recommend.*restaurant|recommend.*place|hungry|starving|takeout|take-?out|delivery|reservation|reservations|michelin|yelp|zagat|foodie|gastropub|tapas|dim sum|bbq|barbecue|food truck|brewery|winery|cocktail bar|speakeasy|rooftop bar|happy hour)\b/i.test(q) ||
/where.*(eat|food|drink|grab|dine)|best.*(brunch|lunch|dinner|food|restaurant|eat|spot)|good.*(food|restaurant|eat|spot)|what'?s good to eat/i.test(q)
}
@@ -81,6 +81,19 @@ export function isPlaceLikeResponse(text: string): boolean {
(text.match(/\b(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill)\b/gi)?.length ?? 0) >= 2
}
// ─── Code classifiers ───────────────────────────────────────────
export function isCodeQuery(q: string): boolean {
if (!q) return false
return /\b(code|coding|programming|function|algorithm|implement|debug|syntax|API|library|framework|snippet|script|regex|refactor|compile|runtime|variable|class|method|module|package|dependency|typescript|javascript|python|rust|go|java|swift|kotlin|ruby|php|css|html|sql|bash|shell|cli|terminal|git|docker|webpack|vite|npm|yarn|pnpm)\b/i.test(q) ||
/write (me )?a |how (do i|to) (code|program|implement|build|create|make)|show me.*code|code (example|sample)|fix.*(bug|error|issue)|what does this code/i.test(q)
}
export function isCodeLikeResponse(text: string): boolean {
const codeBlockCount = (text.match(/```[\s\S]*?```/g) ?? []).length
return codeBlockCount >= 3
}
// ─── Nostr classifiers ──────────────────────────────────────────
export function isNostrQuery(q: string): boolean {
@@ -146,9 +159,10 @@ export function preferredFirstTab(userQuery: string): ContentTab | null {
if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song'
if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
if (/\b(book|books|read|reading|novel|author|nonfiction|non-fiction)\b/.test(q)) return 'book'
if (/\b(tv show|tv series|series|television|streaming|binge|watch)\b/.test(q)) return 'tvshow'
if (/\b(tv\b|tv show|tv series|series|television|streaming|binge|watch)\b/.test(q)) return 'tvshow'
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|hungry)\b/.test(q)) return 'place'
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizza|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|hungry)\b/.test(q)) return 'place'
if (isCodeQuery(q)) return 'code'
if (isAppQuery(q)) return 'app'
if (isNostrQuery(q)) return 'nostr'
if (isNewsQuery(q)) return 'news'
@@ -170,46 +184,12 @@ export function filterTabsByContext(
hasMagazine: boolean,
hasNostr: boolean,
hasApps: boolean,
hasCode = false,
): ContentTab[] {
const q = userQuery.toLowerCase().trim()
const preferred = preferredFirstTab(userQuery)
// Nostr query → prioritize nostr tab with magazine/websites/apps as secondary
if (isNostrQuery(q)) {
const tabs: ContentTab[] = ['nostr']
if (hasApps) tabs.push('app')
if (hasMagazine) tabs.push('magazine')
if (hasWebsites) tabs.push('websites')
return tabs
}
// App query → prioritize apps tab
if (isAppQuery(q)) {
const tabs: ContentTab[] = []
if (hasApps) tabs.push('app')
if (hasNostr) tabs.push('nostr')
if (hasMagazine) tabs.push('magazine')
if (hasWebsites) tabs.push('websites')
return tabs.length > 0 ? tabs : hasNostr ? ['nostr'] : []
}
if (isNewsQuery(q)) {
const tabs: ContentTab[] = []
if (hasMagazine) tabs.push('magazine')
if (hasNews) tabs.push('news')
if (hasWebsites) tabs.push('websites')
if (hasPodcasts) tabs.push('podcast')
return tabs
}
if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasWebsites && !hasNostr && !hasApps) {
return ['magazine']
}
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasMagazine && !hasNostr && !hasApps) {
return ['websites']
}
// Build the full set of detected content tabs
const all: ContentTab[] = []
if (hasFilms) all.push('film')
if (hasBooks) all.push('book')
@@ -218,12 +198,44 @@ export function filterTabsByContext(
if (hasPlaces) all.push('place')
if (hasSongs) all.push('song')
if (hasPodcasts) all.push('podcast')
if (hasCode) all.push('code')
if (hasApps) all.push('app')
if (hasMagazine) all.push('magazine')
if (hasNews) all.push('news')
if (hasWebsites) all.push('websites')
if (hasNostr) all.push('nostr')
// Helper: prioritize certain tabs first, then append remaining detected content
const prioritize = (priority: ContentTab[]): ContentTab[] => {
const present = priority.filter(t => all.includes(t))
const rest = all.filter(t => !present.includes(t))
return [...present, ...rest]
}
// Nostr query → prioritize nostr tab
if (isNostrQuery(q)) {
return prioritize(['nostr', 'app', 'magazine', 'websites'])
}
// App query → prioritize apps tab
if (isAppQuery(q)) {
const result = prioritize(['app', 'nostr', 'magazine', 'websites'])
return result.length > 0 ? result : hasNostr ? ['nostr'] : []
}
// News query → prioritize news/magazine tabs
if (isNewsQuery(q)) {
return prioritize(['magazine', 'news', 'websites', 'podcast'])
}
if (hasMagazine && all.length === 1) {
return ['magazine']
}
if (hasWebsites && all.length === 1) {
return ['websites']
}
if (preferred && all.includes(preferred)) {
const rest = all.filter((t) => t !== preferred)
return [preferred, ...rest]
@@ -22,6 +22,8 @@ const fileTree = shallowRef<FileEntry[]>([])
const activeFile = ref<string | null>(null)
const activeFileContent = ref<string>('')
const activeFileLanguage = ref<string>('plaintext')
const selectedDesignTokens = ref<string[]>([])
const selectedFiles = ref<string[]>([])
// Demo projects path
const PROJECTS_ROOT = '/Users/dorian/Projects'
@@ -110,6 +112,36 @@ export function useCodeContext() {
activeFile.value = null
activeFileContent.value = ''
fileTree.value = []
selectedDesignTokens.value = []
selectedFiles.value = []
}
function toggleDesignToken(id: string): void {
const idx = selectedDesignTokens.value.indexOf(id)
if (idx >= 0) selectedDesignTokens.value.splice(idx, 1)
else selectedDesignTokens.value.push(id)
}
function isDesignTokenSelected(id: string): boolean {
return selectedDesignTokens.value.includes(id)
}
function clearDesignTokens(): void {
selectedDesignTokens.value = []
}
function toggleFileSelection(path: string): void {
const idx = selectedFiles.value.indexOf(path)
if (idx >= 0) selectedFiles.value.splice(idx, 1)
else selectedFiles.value.push(path)
}
function isFileSelected(path: string): boolean {
return selectedFiles.value.includes(path)
}
function clearFileSelection(): void {
selectedFiles.value = []
}
function selectProject(project: ProjectInfo): void {
@@ -231,6 +263,8 @@ export function useCodeContext() {
activeFile,
activeFileContent,
activeFileLanguage,
selectedDesignTokens,
selectedFiles,
// Actions
enterCodeMode,
@@ -241,5 +275,11 @@ export function useCodeContext() {
detectLanguage,
createProject,
clearActiveFile,
toggleDesignToken,
isDesignTokenSelected,
clearDesignTokens,
toggleFileSelection,
isFileSelected,
clearFileSelection,
}
}
@@ -11,13 +11,14 @@ import {
extractMagazineSections, extractMagazineHeroImage,
extractMarkdownLinks, extractBoldDomainLinks, extractBareDomainLinks, mergeNewsResults,
extractFilmIds, extractSongIds, extractPodcastIds,
extractApps,
extractApps, extractCodeBlocks,
stripFilmTags, stripSongTags, stripPodcastTags, stripContentTags, stripMarkdownLinks,
} from './contentExtraction'
import type { AppEntry } from './contentExtraction'
import type { AppEntry, CodeBlock } from './contentExtraction'
import {
isNewsQuery, isNewsLikeResponse, isTVQuery,
isNostrQuery, isNostrLikeResponse,
isCodeQuery, isCodeLikeResponse,
filterTabsByContext, extractQueryContext,
} from './contentFiltering'
export type { ContentTab, MagazineSection } from './contentFiltering'
@@ -37,6 +38,7 @@ const panelPodcasts = ref<Podcast[]>([])
const panelImages = ref<ImageItem[]>([])
const panelPlaces = ref<Place[]>([])
const panelApps = ref<AppEntry[]>([])
const panelCodeBlocks = ref<CodeBlock[]>([])
const selectedFilm = ref<Film | null>(null)
const selectedBook = ref<Book | null>(null)
const selectedTVSeries = ref<TVSeries | null>(null)
@@ -95,6 +97,10 @@ export function useContentPanel() {
const apps = extractApps(text, userQuery)
const hasApps = apps.length > 0
// Code block extraction
const codeBlocks = extractCodeBlocks(text)
const hasCode = codeBlocks.length > 0 && (isCodeQuery(userQuery) || isCodeLikeResponse(text))
// Nostr detection
const hasNostr = isNostrQuery(userQuery) || isNostrLikeResponse(text)
@@ -132,7 +138,7 @@ export function useContentPanel() {
})
}
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine, hasNostr, hasApps)
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine, hasNostr, hasApps, hasCode)
availableTabs.value = tabs.length > 0 ? tabs : ['film']
activeTab.value = tabs[0] ?? 'film'
@@ -147,6 +153,7 @@ export function useContentPanel() {
const showWebsitesTab = tabs.includes('websites')
const showMagazine = tabs.includes('magazine')
const showApps = tabs.includes('app')
const showCode = tabs.includes('code')
const visibleFilms = showFilms ? films : []
const visibleBooks = showBooks ? books : []
@@ -159,6 +166,7 @@ export function useContentPanel() {
const visibleWebsites = showWebsitesTab ? mergedWebsites : []
const visibleMagazineSections = showMagazine ? magazineSections : []
const visibleApps = showApps ? apps : []
const visibleCodeBlocks = showCode ? codeBlocks : []
panelFilms.value = visibleFilms
panelBooks.value = visibleBooks
@@ -170,6 +178,7 @@ export function useContentPanel() {
panelWebResults.value = visibleNews
panelWebsites.value = visibleWebsites
panelApps.value = visibleApps
panelCodeBlocks.value = visibleCodeBlocks
panelMagazineSections.value = visibleMagazineSections
panelMagazineHeroImage.value = showMagazine
? (extractMagazineHeroImage(text) ?? webResults[0]?.imgSrc ?? null)
@@ -224,6 +233,7 @@ export function useContentPanel() {
const ctx = extractQueryContext(userQuery)
panelTitle.value = ctx ? `${ctx} — Brief` : 'AI Brief'
}
else if (visibleCodeBlocks.length > 0) panelTitle.value = `${visibleCodeBlocks.length} Code Blocks`
else if (visibleApps.length > 0) panelTitle.value = `${visibleApps.length} Apps`
else if (visibleWebsites.length > 0) panelTitle.value = `${visibleWebsites.length} Websites`
else if (hasNostr) panelTitle.value = 'Nostr'
@@ -258,6 +268,8 @@ export function useContentPanel() {
const hasWebsites = websitesLinks.length > 0
const apps = extractApps(text, userQuery)
const hasApps = apps.length > 0
const codeBlocks = extractCodeBlocks(text)
const hasCode = codeBlocks.length > 0 && (isCodeQuery(userQuery) || isCodeLikeResponse(text))
const hasNostr = isNostrQuery(userQuery) || isNostrLikeResponse(text)
const images = extractAllImages(text, userQuery)
const places = extractAllPlaces(text, userQuery)
@@ -268,7 +280,7 @@ export function useContentPanel() {
/sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening|ETF|inflow|trading at|key developments|price recovery|institutional|analyst watch|market cap/i.test(text) ||
(!hasAnyOtherInline && !hasWebsites && magazineSections.length >= 2)
)
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine, hasNostr, hasApps)
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine, hasNostr, hasApps, hasCode)
return {
films: tabs.includes('film') ? films : [],
books: tabs.includes('book') ? books : [],
@@ -281,6 +293,7 @@ export function useContentPanel() {
websitesLinks: tabs.includes('websites') ? websitesLinks : [],
magazineSections: tabs.includes('magazine') ? magazineSections : [],
apps: tabs.includes('app') ? apps : [],
codeBlocks: tabs.includes('code') ? codeBlocks : [],
hasNostr,
}
}
@@ -422,6 +435,7 @@ export function useContentPanel() {
panelWebResults,
panelWebsites,
panelApps,
panelCodeBlocks,
panelMagazineSections,
panelMagazineHeroImage,
selectedFilm,
@@ -450,6 +464,7 @@ export function useContentPanel() {
extractAllSongs,
extractPodcastIds,
extractAllPodcasts,
extractCodeBlocks,
getContextualInlineContent,
updatePanelFromText,
stripFilmTags,