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:
co-authored by
Claude Opus 4.6
parent
75e9274582
commit
f0eb4383bd
@@ -0,0 +1,702 @@
|
||||
/**
|
||||
* Extraction Quality Tests
|
||||
*
|
||||
* Tests real-world AI response patterns to verify content surfacing.
|
||||
* Each test simulates a user query + AI response and checks that
|
||||
* the right content types are extracted with correct data.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
extractAllFilms,
|
||||
extractAllSongs,
|
||||
extractAllPodcasts,
|
||||
extractAllBooks,
|
||||
extractAllTVSeries,
|
||||
extractAllPlaces,
|
||||
extractAllImages,
|
||||
extractCodeBlocks,
|
||||
extractApps,
|
||||
} from '@/composables/contentExtraction'
|
||||
import {
|
||||
isBookQuery, isBookLikeResponse,
|
||||
isTVQuery, isPlaceQuery, isPlaceLikeResponse,
|
||||
isMusicQuery, isCodeQuery, isCodeLikeResponse,
|
||||
isNewsQuery, isWebsitesQuery, isAppQuery,
|
||||
filterTabsByContext,
|
||||
} from '@/composables/contentFiltering'
|
||||
|
||||
// ─── Helper: simulate full pipeline ─────────────────────────────
|
||||
|
||||
function extractAll(text: string, userQuery: string) {
|
||||
const films = extractAllFilms(text)
|
||||
const songs = extractAllSongs(text, userQuery)
|
||||
const podcasts = extractAllPodcasts(text)
|
||||
const books = extractAllBooks(text, userQuery)
|
||||
const tvSeries = extractAllTVSeries(text, userQuery)
|
||||
const images = extractAllImages(text, userQuery)
|
||||
const places = extractAllPlaces(text, userQuery)
|
||||
const codeBlocks = extractCodeBlocks(text)
|
||||
const apps = extractApps(text, userQuery)
|
||||
return { films, songs, podcasts, books, tvSeries, images, places, codeBlocks, apps }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// BOOKS — pattern-based extraction
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Books: pattern extraction from AI responses', () => {
|
||||
it('extracts books from "Title by Author" format', () => {
|
||||
const text = `Here are some essential Bitcoin books:
|
||||
|
||||
The Bitcoin Standard by Saifedean Ammous is a great starting point. It covers the history of money and why Bitcoin matters.
|
||||
|
||||
You might also enjoy Mastering Bitcoin by Andreas Antonopoulos for the technical side.`
|
||||
const books = extractAllBooks(text, 'recommend bitcoin books')
|
||||
expect(books.length).toBeGreaterThanOrEqual(2)
|
||||
expect(books.some(b => b.title.includes('Bitcoin Standard'))).toBe(true)
|
||||
expect(books.some(b => b.title.includes('Mastering Bitcoin'))).toBe(true)
|
||||
})
|
||||
|
||||
it('extracts books from numbered list with bold and "by"', () => {
|
||||
const text = `Top books on money:
|
||||
|
||||
1. **The Bitcoin Standard** by Saifedean Ammous
|
||||
2. **The Fiat Standard** by Saifedean Ammous
|
||||
3. **Broken Money** by Lyn Alden
|
||||
4. **The Price of Tomorrow** by Jeff Booth`
|
||||
const books = extractAllBooks(text, 'books about money')
|
||||
expect(books.length).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
|
||||
it('extracts books from em-dash format', () => {
|
||||
const text = `Essential reads:
|
||||
|
||||
- The Sovereign Individual — James Dale Davidson (1997)
|
||||
- The Bitcoin Standard — Saifedean Ammous (2018)
|
||||
- Broken Money — Lyn Alden (2023)`
|
||||
const books = extractAllBooks(text, 'book recommendations')
|
||||
expect(books.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('single "by Author" triggers isBookLikeResponse', () => {
|
||||
const text = 'I highly recommend The Bitcoin Standard by Saifedean Ammous. It is an excellent book on monetary theory.'
|
||||
expect(isBookLikeResponse(text)).toBe(true)
|
||||
})
|
||||
|
||||
it('isBookQuery matches common book queries', () => {
|
||||
expect(isBookQuery('best books about bitcoin')).toBe(true)
|
||||
expect(isBookQuery('what should I read')).toBe(true)
|
||||
expect(isBookQuery('recommend me a novel')).toBe(true)
|
||||
expect(isBookQuery('any good nonfiction books')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not extract books from film responses', () => {
|
||||
const text = '[[film_ext:Inception|2010|Christopher Nolan]] is great. Directed by Christopher Nolan.'
|
||||
const books = extractAllBooks(text, 'what is inception')
|
||||
expect(books).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TV SERIES — pattern-based extraction
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('TV Series: pattern extraction from AI responses', () => {
|
||||
it('extracts TV shows from numbered list with years', () => {
|
||||
const text = `Best TV dramas of all time:
|
||||
|
||||
1. **Breaking Bad** (2008–2013)
|
||||
2. **The Wire** (2002–2008)
|
||||
3. **The Sopranos** (1999–2007)
|
||||
4. **Better Call Saul** (2015–2022)`
|
||||
const series = extractAllTVSeries(text, 'best tv shows')
|
||||
expect(series.length).toBeGreaterThanOrEqual(4)
|
||||
expect(series.some(s => s.title.includes('Breaking Bad'))).toBe(true)
|
||||
})
|
||||
|
||||
it('extracts shows with "N seasons" format', () => {
|
||||
const text = `Some great binge watches:
|
||||
|
||||
**Breaking Bad** — 5 seasons of incredible storytelling
|
||||
**Better Call Saul** — 6 seasons, a worthy prequel`
|
||||
const series = extractAllTVSeries(text, 'what should I binge')
|
||||
expect(series.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('isTVLikeResponse triggers with single season mention', () => {
|
||||
const text = 'Breaking Bad ran for 5 seasons on AMC and is widely considered one of the best TV dramas ever made.'
|
||||
expect(isTVQuery('best tv shows')).toBe(true)
|
||||
// Should pass with just 1 "season" mention now
|
||||
const hasKeyword = /\b(season|episodes?|showrunner|streaming|renewed|cancelled|premiere|network|HBO|Netflix|AMC)\b/i.test(text)
|
||||
expect(hasKeyword).toBe(true)
|
||||
})
|
||||
|
||||
it('does not extract TV from non-TV contexts', () => {
|
||||
const text = 'Bitcoin has seen a new season of adoption. The network grows stronger.'
|
||||
const series = extractAllTVSeries(text, 'what is bitcoin')
|
||||
expect(series).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PLACES — pattern-based extraction
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Places: pattern extraction from AI responses', () => {
|
||||
it('extracts places with explicit type words', () => {
|
||||
const text = `Best pizza in New York:
|
||||
|
||||
1. **Joe's Pizza** — classic New York pizza joint since 1975
|
||||
2. **Di Fara Pizza** — legendary Brooklyn pizzeria`
|
||||
const places = extractAllPlaces(text, 'best pizza in new york')
|
||||
expect(places.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('extracts places from bold + description without type word (place query)', () => {
|
||||
const text = `Great dinner spots in Austin:
|
||||
|
||||
1. **Franklin Barbecue** — World-famous brisket, expect a long line but worth every minute
|
||||
2. **Uchi** — Innovative Japanese cuisine with a Texas twist
|
||||
3. **Launderette** — New American comfort food in a converted laundromat`
|
||||
const places = extractAllPlaces(text, 'where to eat in austin')
|
||||
expect(places.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('isPlaceQuery matches food/restaurant queries', () => {
|
||||
expect(isPlaceQuery('best restaurants in london')).toBe(true)
|
||||
expect(isPlaceQuery('where to eat in tokyo')).toBe(true)
|
||||
expect(isPlaceQuery('good brunch spots')).toBe(true)
|
||||
expect(isPlaceQuery('I am hungry')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not extract places from non-place contexts', () => {
|
||||
const text = '**Bitcoin** — A peer-to-peer electronic cash system'
|
||||
const places = extractAllPlaces(text, 'what is bitcoin')
|
||||
expect(places).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SONGS — coexistence with other content types
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Songs: coexistence with other content', () => {
|
||||
it('returns explicit song tags even when film tags present', () => {
|
||||
const text = `Great movie with an amazing soundtrack:
|
||||
[[film_ext:Guardians of the Galaxy|2014|James Gunn]]
|
||||
|
||||
Featured songs:
|
||||
[[song_ext:Hooked on a Feeling|Blue Swede|1974]]
|
||||
[[song_ext:Come and Get Your Love|Redbone|1974]]`
|
||||
const songs = extractAllSongs(text, 'guardians of the galaxy soundtrack')
|
||||
expect(songs.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('returns explicit song tags even when book tags present', () => {
|
||||
const text = `[[book_ext:Norwegian Wood|Haruki Murakami|1987]]
|
||||
|
||||
The title references the Beatles song:
|
||||
[[song_ext:Norwegian Wood|The Beatles|1965]]`
|
||||
const songs = extractAllSongs(text, 'tell me about norwegian wood')
|
||||
expect(songs.length).toBeGreaterThanOrEqual(1)
|
||||
expect(songs[0].title).toBe('Norwegian Wood')
|
||||
})
|
||||
|
||||
it('skips pattern-based songs when film tags present (no explicit song tags)', () => {
|
||||
const text = `[[film_ext:Inception|2010|Christopher Nolan]]
|
||||
|
||||
Great film. The music by Hans Zimmer is amazing — "Time" is iconic.`
|
||||
const songs = extractAllSongs(text, 'tell me about inception')
|
||||
expect(songs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('extracts songs for music queries', () => {
|
||||
const text = `Here are some great rock songs:
|
||||
|
||||
"Bohemian Rhapsody" by Queen is a masterpiece.
|
||||
"Stairway to Heaven" by Led Zeppelin is another classic.`
|
||||
const songs = extractAllSongs(text, 'best rock songs')
|
||||
expect(songs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// CODE BLOCKS — extraction and classification
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Code blocks: extraction and classification', () => {
|
||||
it('extracts fenced code blocks with language', () => {
|
||||
const text = `Here's a simple function:
|
||||
|
||||
\`\`\`typescript
|
||||
function greet(name: string): string {
|
||||
return \`Hello, \${name}!\`
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
And here's the Python version:
|
||||
|
||||
\`\`\`python
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
\`\`\`
|
||||
`
|
||||
const blocks = extractCodeBlocks(text)
|
||||
expect(blocks).toHaveLength(2)
|
||||
expect(blocks[0].language).toBe('typescript')
|
||||
expect(blocks[1].language).toBe('python')
|
||||
expect(blocks[0].code).toContain('function greet')
|
||||
})
|
||||
|
||||
it('extracts code blocks without language specifier', () => {
|
||||
const text = `Run this command:
|
||||
|
||||
\`\`\`
|
||||
npm install
|
||||
\`\`\`
|
||||
`
|
||||
const blocks = extractCodeBlocks(text)
|
||||
expect(blocks).toHaveLength(1)
|
||||
expect(blocks[0].language).toBe('text')
|
||||
})
|
||||
|
||||
it('extracts label from preceding heading', () => {
|
||||
const text = `**Setup Script**
|
||||
\`\`\`bash
|
||||
#!/bin/bash
|
||||
echo "Setting up..."
|
||||
\`\`\`
|
||||
`
|
||||
const blocks = extractCodeBlocks(text)
|
||||
expect(blocks).toHaveLength(1)
|
||||
expect(blocks[0].label).toBe('Setup Script')
|
||||
})
|
||||
|
||||
it('isCodeQuery matches programming queries', () => {
|
||||
expect(isCodeQuery('write me a function to sort an array')).toBe(true)
|
||||
expect(isCodeQuery('how to implement binary search in python')).toBe(true)
|
||||
expect(isCodeQuery('debug this javascript error')).toBe(true)
|
||||
expect(isCodeQuery('best typescript libraries')).toBe(true)
|
||||
})
|
||||
|
||||
it('isCodeQuery does not match non-code queries', () => {
|
||||
expect(isCodeQuery('best movies of 2024')).toBe(false)
|
||||
expect(isCodeQuery('recommend restaurants in paris')).toBe(false)
|
||||
})
|
||||
|
||||
it('isCodeLikeResponse triggers with 3+ code blocks', () => {
|
||||
const text = '```js\na\n```\n```py\nb\n```\n```go\nc\n```'
|
||||
expect(isCodeLikeResponse(text)).toBe(true)
|
||||
})
|
||||
|
||||
it('isCodeLikeResponse does not trigger with 1-2 blocks', () => {
|
||||
const text = '```js\na\n```\nSome text\n```py\nb\n```'
|
||||
expect(isCodeLikeResponse(text)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// IMAGES — threshold behavior
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Images: threshold and alt text', () => {
|
||||
it('returns single image for image queries', () => {
|
||||
const text = ''
|
||||
const images = extractAllImages(text, 'show me a sunset image')
|
||||
expect(images).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns single image with meaningful alt text', () => {
|
||||
const text = ''
|
||||
const images = extractAllImages(text, 'bitcoin price')
|
||||
expect(images).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('skips single image without alt text for non-image query', () => {
|
||||
const text = 'https://example.com/random.jpg'
|
||||
const images = extractAllImages(text, 'what is bitcoin')
|
||||
expect(images).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 2+ images for any query', () => {
|
||||
const text = 'https://example.com/a.jpg https://example.com/b.png'
|
||||
const images = extractAllImages(text, 'what is bitcoin')
|
||||
expect(images).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TAB FILTERING — comprehensive routing
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Tab filtering: correct tabs surfaced', () => {
|
||||
it('surfaces book tab for book query with books', () => {
|
||||
const tabs = filterTabsByContext('best bitcoin books', false, false, false, true, false, false, false, false, false, false, false, false, false)
|
||||
expect(tabs).toContain('book')
|
||||
})
|
||||
|
||||
it('surfaces code tab for code query with code', () => {
|
||||
const tabs = filterTabsByContext('write a typescript function', false, false, false, false, false, false, false, false, false, false, false, false, true)
|
||||
expect(tabs).toContain('code')
|
||||
expect(tabs[0]).toBe('code') // should be first
|
||||
})
|
||||
|
||||
it('surfaces multiple tabs when multiple content types present', () => {
|
||||
const tabs = filterTabsByContext('movies and music', true, true, false, false, false, false, false, false, false, false, false, false, false)
|
||||
expect(tabs).toContain('film')
|
||||
expect(tabs).toContain('song')
|
||||
})
|
||||
|
||||
it('surfaces place tab for restaurant query', () => {
|
||||
const tabs = filterTabsByContext('best restaurants in london', false, false, false, false, false, false, true, false, false, false, false, false, false)
|
||||
expect(tabs).toContain('place')
|
||||
expect(tabs[0]).toBe('place')
|
||||
})
|
||||
|
||||
it('surfaces TV tab for TV query', () => {
|
||||
const tabs = filterTabsByContext('best tv shows to binge', false, false, false, false, true, false, false, false, false, false, false, false, false)
|
||||
expect(tabs).toContain('tvshow')
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// FULL PIPELINE — realistic AI responses
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Full pipeline: realistic AI responses', () => {
|
||||
it('book recommendation response', () => {
|
||||
const query = 'recommend some books about bitcoin and economics'
|
||||
const response = `Here are my top recommendations:
|
||||
|
||||
1. **The Bitcoin Standard** by Saifedean Ammous — The definitive work on why Bitcoin matters for sound money.
|
||||
2. **Broken Money** by Lyn Alden — A thorough analysis of the monetary system and how Bitcoin fits in.
|
||||
3. **The Fiat Standard** by Saifedean Ammous — The sequel exploring the fiat monetary system.
|
||||
4. **The Price of Tomorrow** by Jeff Booth — How deflation will shape the future economy.
|
||||
5. **Mastering Bitcoin** by Andreas Antonopoulos — Technical deep-dive into how Bitcoin works.
|
||||
|
||||
Each of these books offers a unique perspective on money, technology, and economics.`
|
||||
|
||||
const result = extractAll(response, query)
|
||||
expect(result.books.length).toBeGreaterThanOrEqual(4)
|
||||
expect(result.films).toHaveLength(0)
|
||||
expect(result.songs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('restaurant recommendation response', () => {
|
||||
const query = 'best sushi in tokyo'
|
||||
const response = `Here are Tokyo's finest sushi restaurants:
|
||||
|
||||
1. **Sukiyabashi Jiro** — The legendary 3-Michelin-star omakase experience in Ginza
|
||||
2. **Sushi Saito** — Another 3-star establishment known for exquisite nigiri
|
||||
3. **Sushi Yoshitake** — Intimate counter seating with seasonal specialties
|
||||
4. **Kyubey** — Classic Ginza sushi since 1935, welcoming to tourists`
|
||||
|
||||
const result = extractAll(response, query)
|
||||
expect(result.places.length).toBeGreaterThanOrEqual(3)
|
||||
expect(result.books).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('coding response with multiple code blocks', () => {
|
||||
const query = 'implement binary search in typescript'
|
||||
const response = `Here's a binary search implementation:
|
||||
|
||||
**Iterative approach**
|
||||
\`\`\`typescript
|
||||
function binarySearch(arr: number[], target: number): number {
|
||||
let left = 0
|
||||
let right = arr.length - 1
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2)
|
||||
if (arr[mid] === target) return mid
|
||||
if (arr[mid] < target) left = mid + 1
|
||||
else right = mid - 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Recursive approach**
|
||||
\`\`\`typescript
|
||||
function binarySearchRecursive(arr: number[], target: number, left = 0, right = arr.length - 1): number {
|
||||
if (left > right) return -1
|
||||
const mid = Math.floor((left + right) / 2)
|
||||
if (arr[mid] === target) return mid
|
||||
if (arr[mid] < target) return binarySearchRecursive(arr, target, mid + 1, right)
|
||||
return binarySearchRecursive(arr, target, left, mid - 1)
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Usage**
|
||||
\`\`\`typescript
|
||||
const arr = [1, 3, 5, 7, 9, 11]
|
||||
console.log(binarySearch(arr, 7)) // 3
|
||||
console.log(binarySearchRecursive(arr, 7)) // 3
|
||||
\`\`\`
|
||||
`
|
||||
const result = extractAll(response, query)
|
||||
expect(result.codeBlocks.length).toBeGreaterThanOrEqual(3)
|
||||
expect(result.codeBlocks[0].language).toBe('typescript')
|
||||
expect(result.codeBlocks[0].label).toBe('Iterative approach')
|
||||
expect(isCodeQuery(query)).toBe(true)
|
||||
expect(isCodeLikeResponse(response)).toBe(true)
|
||||
})
|
||||
|
||||
it('TV show recommendation response', () => {
|
||||
const query = 'best tv series of all time'
|
||||
const response = `Here are the greatest TV series ever made:
|
||||
|
||||
1. **Breaking Bad** (2008–2013) — A chemistry teacher turned drug lord. 5 seasons of perfect television.
|
||||
2. **The Wire** (2002–2008) — A sprawling look at Baltimore's institutions. 5 seasons.
|
||||
3. **The Sopranos** (1999–2007) — The show that started the golden age of TV. 6 seasons.
|
||||
4. **Mad Men** (2007–2015) — 1960s advertising world, beautifully crafted. 7 seasons.
|
||||
5. **Chernobyl** (2019) — A devastating miniseries about the nuclear disaster.`
|
||||
|
||||
const result = extractAll(response, query)
|
||||
expect(result.tvSeries.length).toBeGreaterThanOrEqual(4)
|
||||
expect(result.films).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('mixed film and song tags coexist', () => {
|
||||
const query = 'tell me about the guardians of the galaxy soundtrack'
|
||||
const response = `Guardians of the Galaxy has one of the best movie soundtracks:
|
||||
|
||||
[[film_ext:Guardians of the Galaxy|2014|James Gunn]]
|
||||
|
||||
The "Awesome Mix Vol. 1" features:
|
||||
[[song_ext:Hooked on a Feeling|Blue Swede|1974]]
|
||||
[[song_ext:Come and Get Your Love|Redbone|1974]]
|
||||
[[song_ext:Spirit in the Sky|Norman Greenbaum|1969]]
|
||||
[[song_ext:Escape (The Piña Colada Song)|Rupert Holmes|1979]]`
|
||||
|
||||
const result = extractAll(response, query)
|
||||
expect(result.films.length).toBeGreaterThanOrEqual(1)
|
||||
expect(result.songs.length).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// EDGE CASES — tricky patterns that commonly fail
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Edge cases: commonly failing patterns', () => {
|
||||
it('does not false-positive books from link markdown', () => {
|
||||
const text = 'Check out [The Bitcoin Standard](https://example.com) by visiting the website.'
|
||||
const books = extractAllBooks(text, 'bitcoin resources')
|
||||
// Should not extract "The Bitcoin Standard" as a book from a markdown link
|
||||
expect(books.every(b => !b.title.includes('Bitcoin Standard'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not extract place names from non-place bold text', () => {
|
||||
const text = `Key Bitcoin concepts:
|
||||
|
||||
1. **Proof of Work** — The consensus mechanism that secures Bitcoin
|
||||
2. **Hash Rate** — The computational power of the network`
|
||||
const places = extractAllPlaces(text, 'explain bitcoin')
|
||||
expect(places).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles quoted titles in book extraction', () => {
|
||||
const text = '"The Bitcoin Standard" by Saifedean Ammous is essential reading for understanding sound money.'
|
||||
const books = extractAllBooks(text, 'what books should I read about bitcoin')
|
||||
expect(books.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('does not extract TV series from Bitcoin season references', () => {
|
||||
const text = 'This is a new season for Bitcoin adoption. The network hashrate reached new highs.'
|
||||
const series = extractAllTVSeries(text, 'bitcoin news')
|
||||
expect(series).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('extracts books from smart/curly quotes', () => {
|
||||
const text = '\u201CThe Bitcoin Standard\u201D by Saifedean Ammous is a must-read.'
|
||||
const books = extractAllBooks(text, 'bitcoin books')
|
||||
expect(books.length).toBeGreaterThanOrEqual(1)
|
||||
expect(books[0].title).toContain('Bitcoin Standard')
|
||||
})
|
||||
|
||||
it('extracts places when query mentions pizza', () => {
|
||||
expect(isPlaceQuery('best pizza near me')).toBe(true)
|
||||
expect(isPlaceQuery('pizza recommendations')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not false-positive songs from film director "by" pattern', () => {
|
||||
const text = '[[film_ext:Inception|2010|Christopher Nolan]]\n\nInception, directed by Christopher Nolan, is a mind-bending thriller.'
|
||||
const songs = extractAllSongs(text, 'tell me about inception')
|
||||
expect(songs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles books with single-word titles in bold', () => {
|
||||
const text = '1. **Sapiens** by Yuval Noah Harari — A brief history of humankind'
|
||||
const books = extractAllBooks(text, 'best nonfiction books')
|
||||
expect(books.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('extracts multiple places from varied formatting', () => {
|
||||
const query = 'where to eat in paris'
|
||||
const text = `Top restaurants in Paris:
|
||||
|
||||
1. **Le Comptoir du Panth\u00e9on** \u2014 Classic French bistro with incredible steak frites
|
||||
2. **Chez Janou** \u2014 Famous for its chocolate mousse, cozy Proven\u00e7al atmosphere
|
||||
3. **L'As du Fallafel** \u2014 Best falafel in the Marais, always a queue`
|
||||
const places = extractAllPlaces(text, query)
|
||||
expect(places.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('extracts TV shows from "created by" format', () => {
|
||||
const text = `1. **Breaking Bad** (2008\u20132013) \u2014 Created by Vince Gilligan. A chemistry teacher becomes a drug lord.
|
||||
2. **The Wire** (2002\u20132008) \u2014 Created by David Simon. A deep look at Baltimore institutions.`
|
||||
const series = extractAllTVSeries(text, 'best tv dramas')
|
||||
expect(series.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('does not extract books when response is about films', () => {
|
||||
const text = `Great sci-fi films:
|
||||
|
||||
1. **Blade Runner** (1982) \u2014 Directed by Ridley Scott
|
||||
2. **2001: A Space Odyssey** (1968) \u2014 Directed by Stanley Kubrick`
|
||||
const books = extractAllBooks(text, 'best sci-fi movies')
|
||||
expect(books).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('extracts code blocks with varied languages', () => {
|
||||
const text = `**HTML**
|
||||
\`\`\`html
|
||||
<div class="container">Hello</div>
|
||||
\`\`\`
|
||||
|
||||
**CSS**
|
||||
\`\`\`css
|
||||
.container { color: red; }
|
||||
\`\`\`
|
||||
|
||||
**JavaScript**
|
||||
\`\`\`javascript
|
||||
document.querySelector('.container')
|
||||
\`\`\`
|
||||
`
|
||||
const blocks = extractCodeBlocks(text)
|
||||
expect(blocks).toHaveLength(3)
|
||||
expect(blocks[0].language).toBe('html')
|
||||
expect(blocks[1].language).toBe('css')
|
||||
expect(blocks[2].language).toBe('javascript')
|
||||
})
|
||||
|
||||
it('surfaces correct tabs for multi-content response', () => {
|
||||
// Books + code in same response
|
||||
const tabs = filterTabsByContext(
|
||||
'how to learn programming',
|
||||
false, false, false, true, false, false, false, false, false, false, false, false, true
|
||||
)
|
||||
expect(tabs).toContain('book')
|
||||
expect(tabs).toContain('code')
|
||||
})
|
||||
|
||||
it('prefers place tab first for food queries', () => {
|
||||
const tabs = filterTabsByContext(
|
||||
'best pizza in new york',
|
||||
false, false, false, false, false, false, true, false, false, false, false, false, false
|
||||
)
|
||||
expect(tabs[0]).toBe('place')
|
||||
})
|
||||
|
||||
it('extracts books from prose with "also enjoy" mid-sentence', () => {
|
||||
const text = `I'd recommend starting with The Bitcoin Standard by Saifedean Ammous. You might also enjoy Mastering Bitcoin by Andreas Antonopoulos for the technical deep dive.`
|
||||
const books = extractAllBooks(text, 'bitcoin book recommendations')
|
||||
expect(books.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('extracts places with cuisine type in description', () => {
|
||||
const text = `Best spots in Brooklyn:
|
||||
|
||||
1. **Lucali** — Beloved BYOB pizzeria with incredible thin-crust pies
|
||||
2. **Peter Luger** — Iconic steakhouse since 1887, cash only
|
||||
3. **Olmsted** — Innovative New American restaurant with a backyard garden`
|
||||
const places = extractAllPlaces(text, 'where to eat in brooklyn')
|
||||
expect(places.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('does not false-positive places from tech concepts', () => {
|
||||
const text = `**React** — A JavaScript library for building user interfaces
|
||||
**Vue** — The progressive JavaScript framework
|
||||
**Angular** — A platform for building mobile and desktop web apps`
|
||||
const places = extractAllPlaces(text, 'best javascript frameworks')
|
||||
expect(places).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('extracts songs from "Artist - Title" format', () => {
|
||||
const text = `Classic rock essentials:
|
||||
|
||||
[[song_ext:Stairway to Heaven|Led Zeppelin|1971]]
|
||||
[[song_ext:Hotel California|Eagles|1977]]
|
||||
[[song_ext:Comfortably Numb|Pink Floyd|1979]]`
|
||||
const songs = extractAllSongs(text, 'best classic rock songs')
|
||||
expect(songs.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('handles TV query "what should I watch"', () => {
|
||||
expect(isTVQuery('what should I watch tonight')).toBe(true)
|
||||
expect(isTVQuery('anything good to binge')).toBe(true)
|
||||
expect(isTVQuery('best tv comedies')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles place query with cuisine names', () => {
|
||||
expect(isPlaceQuery('best sushi in tokyo')).toBe(true)
|
||||
expect(isPlaceQuery('good ramen spots')).toBe(true)
|
||||
expect(isPlaceQuery('where to get tacos')).toBe(true)
|
||||
})
|
||||
|
||||
it('full pipeline: mixed books and songs response', () => {
|
||||
const query = 'tell me about norwegian wood'
|
||||
const text = `"Norwegian Wood" can refer to both a novel and a song:
|
||||
|
||||
**The Novel**: "Norwegian Wood" by Haruki Murakami (1987) is a nostalgic story of love and loss set in 1960s Tokyo. It's one of Murakami's most accessible works.
|
||||
|
||||
**The Song**: [[song_ext:Norwegian Wood (This Bird Has Flown)|The Beatles|1965]]
|
||||
|
||||
The Beatles' track from Rubber Soul inspired Murakami's title. The song features George Harrison's sitar playing.`
|
||||
const result = extractAll(text, query)
|
||||
expect(result.books.length).toBeGreaterThanOrEqual(1)
|
||||
expect(result.songs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('full pipeline: place response without explicit type words', () => {
|
||||
const query = 'best brunch in london'
|
||||
const text = `Here are London's best brunch spots:
|
||||
|
||||
1. **Dishoom** — Bombay-inspired breakfast, try the bacon naan roll and chai
|
||||
2. **The Wolseley** — Grand European cafe on Piccadilly, impeccable service
|
||||
3. **Padella** — Fresh handmade pasta, worth the queue at Borough Market
|
||||
4. **Bao** — Taiwanese steamed buns and small plates, Soho or Fitzrovia`
|
||||
const result = extractAll(text, query)
|
||||
expect(result.places.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('extracts TV from [[tv_ext:]] tags with Title|Year|Creator format', () => {
|
||||
const text = `Similar series:\n\n[[tv_ext:Ghost in the Shell: Stand Alone Complex|2002|Kenji Kamiyama]] — Gold standard for tech-noir sci-fi.\n\n[[tv_ext:Cyberpunk: Edgerunners|2022|Hiroyuki Imaishi]] — Stunning animation.\n\n[[tv_ext:Altered Carbon|2018|Laeta Kalogridis]] — Cyberpunk noir.`
|
||||
const result = extractAll(text, 'pantheon news')
|
||||
expect(result.tvSeries.length).toBeGreaterThanOrEqual(3)
|
||||
expect(result.tvSeries[0].title).toBe('Ghost in the Shell: Stand Alone Complex')
|
||||
expect(result.tvSeries[0].year).toBe(2002)
|
||||
expect(result.tvSeries[0].creator).toBe('Kenji Kamiyama')
|
||||
})
|
||||
|
||||
it('news query with TV content surfaces both news and TV tabs', () => {
|
||||
// Simulates the Pantheon response: news query + TV ext tags
|
||||
const tabs = filterTabsByContext(
|
||||
'any news on pantheon season 3',
|
||||
false, false, false, false, true, false, false, false, true, false, false, false, false
|
||||
)
|
||||
expect(tabs).toContain('tvshow')
|
||||
expect(tabs).toContain('websites')
|
||||
})
|
||||
|
||||
it('full pipeline: news query does not surface books or places', () => {
|
||||
const text = `Here are the latest developments:
|
||||
|
||||
Bitcoin has surged past $100,000 for the first time. The rally was driven by institutional adoption and ETF inflows. Major exchanges like Coinbase and Kraken reported record trading volumes.`
|
||||
const result = extractAll(text, 'latest bitcoin news')
|
||||
expect(result.books).toHaveLength(0)
|
||||
expect(result.places).toHaveLength(0)
|
||||
expect(result.tvSeries).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -77,8 +77,13 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="path-glass-bubble rounded-2xl px-4 py-3 flex items-end gap-2 transition-all duration-300"
|
||||
:class="focused ? 'border-white/30' : ''"
|
||||
class="rounded-2xl px-4 py-3 flex items-end gap-2 transition-all duration-300"
|
||||
:class="[
|
||||
isCodeMode
|
||||
? 'bg-accent/15 border border-accent/25 backdrop-blur-xl'
|
||||
: 'path-glass-bubble',
|
||||
focused ? (isCodeMode ? 'border-accent/40' : 'border-white/30') : '',
|
||||
]"
|
||||
>
|
||||
<!-- Image attach button -->
|
||||
<button
|
||||
@@ -131,10 +136,14 @@
|
||||
<!-- Send button -->
|
||||
<button
|
||||
:disabled="!canSend"
|
||||
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200"
|
||||
:class="canSend
|
||||
? 'hover:opacity-80 active:scale-95'
|
||||
: 'opacity-30 cursor-not-allowed'"
|
||||
class="shrink-0 rounded-xl px-3 transition-all duration-200"
|
||||
:class="[
|
||||
isCodeMode ? 'bg-accent/80 text-white' : 'path-glass-button path-glass-button-sm',
|
||||
canSend
|
||||
? 'hover:opacity-80 active:scale-95'
|
||||
: 'opacity-30 cursor-not-allowed',
|
||||
]"
|
||||
:style="isCodeMode ? 'height: 32px' : ''"
|
||||
aria-label="Send message"
|
||||
@click="send"
|
||||
>
|
||||
@@ -172,16 +181,20 @@ const props = withDefaults(
|
||||
disabled?: boolean
|
||||
streaming?: boolean
|
||||
placeholder?: string
|
||||
activeTab?: string
|
||||
replyTo?: { messageId: string; excerpt: string } | null
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
streaming: false,
|
||||
placeholder: 'Message AIUI...',
|
||||
activeTab: '',
|
||||
replyTo: null,
|
||||
}
|
||||
)
|
||||
|
||||
const isCodeMode = computed(() => props.activeTab === 'code')
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [text: string, images: ImageAttachment[]]
|
||||
extract: [text: string]
|
||||
|
||||
@@ -106,7 +106,8 @@
|
||||
<ChatInput
|
||||
:disabled="isStreaming || comparison.isAnyStreaming.value"
|
||||
:streaming="isStreaming || comparison.isAnyStreaming.value"
|
||||
:placeholder="isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
|
||||
:placeholder="activeTab === 'code' ? 'Code...' : isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
|
||||
:active-tab="activeTab"
|
||||
:reply-to="replyTo"
|
||||
@send="handleSend"
|
||||
@extract="handleExtract"
|
||||
|
||||
@@ -87,6 +87,7 @@ const promptPairs = computed<PromptPair[]>(() => {
|
||||
if (content.magazineSections.length > 0) badges.push('Magazine')
|
||||
if ((content.newsLinks?.length ?? 0) > 0) badges.push('News')
|
||||
if ((content.websitesLinks?.length ?? 0) > 0) badges.push('Web')
|
||||
if ((content.codeBlocks?.length ?? 0) > 0) badges.push('Code')
|
||||
if ((content.apps?.length ?? 0) > 0) badges.push('Apps')
|
||||
if (content.hasNostr) badges.push('Nostr')
|
||||
}
|
||||
|
||||
@@ -37,12 +37,29 @@
|
||||
<button
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="text-left p-3 rounded-xl transition-colors group cursor-pointer"
|
||||
:class="isDark
|
||||
? 'bg-white/[0.03] hover:bg-white/[0.07]'
|
||||
: 'bg-black/[0.02] hover:bg-black/[0.05]'"
|
||||
class="text-left p-3 rounded-xl transition-all duration-150 group relative"
|
||||
:class="[
|
||||
codeMode && isDesignTokenSelected(item.id)
|
||||
? 'ring-2 ring-accent/50 bg-accent/10 cursor-pointer'
|
||||
: isDark
|
||||
? 'bg-white/[0.03] hover:bg-white/[0.07] cursor-pointer'
|
||||
: 'bg-black/[0.02] hover:bg-black/[0.05] cursor-pointer',
|
||||
]"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<!-- Selection toggle (top-right) — only this area toggles context selection -->
|
||||
<div
|
||||
v-if="codeMode"
|
||||
class="absolute top-2 right-2 w-5 h-5 rounded-full flex items-center justify-center z-10 cursor-pointer transition-colors"
|
||||
:class="isDesignTokenSelected(item.id)
|
||||
? 'bg-accent'
|
||||
: isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-black/10 hover:bg-black/20'"
|
||||
@click.stop="toggleDesignToken(item.id)"
|
||||
>
|
||||
<svg v-if="isDesignTokenSelected(item.id)" class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Preview swatch for colors -->
|
||||
<div v-if="item.category === 'colors' && item.preview === 'inline'"
|
||||
class="h-8 rounded-md mb-2 border"
|
||||
@@ -92,9 +109,11 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useContentPanel, type DesignSystemItem } from '@/composables/useContentPanel'
|
||||
import { useCodeContext } from '@/composables/useCodeContext'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const { openDesignSystemItem } = useContentPanel()
|
||||
const { codeMode, toggleDesignToken, isDesignTokenSelected } = useCodeContext()
|
||||
|
||||
const activeCategory = ref<string>('all')
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
<button
|
||||
class="w-full text-left flex items-center gap-1.5 py-1 px-2 rounded-lg text-xs transition-colors"
|
||||
:class="[
|
||||
isActive
|
||||
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
|
||||
: isDark ? 'text-white/60 hover:bg-white/[0.04] hover:text-white/80' : 'text-gray-600 hover:bg-black/[0.03] hover:text-gray-800',
|
||||
isSelected
|
||||
? 'bg-accent/15 text-accent ring-1 ring-accent/30'
|
||||
: isActive
|
||||
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
|
||||
: isDark ? 'text-white/60 hover:bg-white/[0.04] hover:text-white/80' : 'text-gray-600 hover:bg-black/[0.03] hover:text-gray-800',
|
||||
]"
|
||||
:style="{ paddingLeft: `${depth * 12 + 8}px` }"
|
||||
@click="handleClick"
|
||||
@@ -33,6 +35,9 @@
|
||||
</svg>
|
||||
|
||||
<span class="truncate">{{ entry.name }}</span>
|
||||
<svg v-if="isSelected" class="w-3 h-3 shrink-0 text-accent ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Children (when expanded) -->
|
||||
@@ -52,7 +57,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import type { FileEntry } from '@/composables/useCodeContext'
|
||||
import { useCodeContext, type FileEntry } from '@/composables/useCodeContext'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: FileEntry
|
||||
@@ -60,6 +65,8 @@ const props = defineProps<{
|
||||
depth: number
|
||||
}>()
|
||||
|
||||
const { isFileSelected } = useCodeContext()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [path: string]
|
||||
}>()
|
||||
@@ -68,6 +75,7 @@ const { isDark } = useTheme()
|
||||
const expanded = ref(props.depth < 1) // Auto-expand first level
|
||||
|
||||
const isActive = computed(() => !props.entry.isDirectory && props.activeFile === props.entry.path)
|
||||
const isSelected = computed(() => !props.entry.isDirectory && isFileSelected(props.entry.path))
|
||||
|
||||
function handleClick() {
|
||||
if (props.entry.isDirectory) {
|
||||
|
||||
@@ -172,10 +172,14 @@ const {
|
||||
activeProject,
|
||||
fileTree,
|
||||
activeFile,
|
||||
codeMode,
|
||||
selectedFiles,
|
||||
selectProject: doSelectProject,
|
||||
openFile,
|
||||
createProject,
|
||||
clearActiveFile,
|
||||
toggleFileSelection,
|
||||
isFileSelected,
|
||||
} = useCodeContext()
|
||||
|
||||
const search = ref('')
|
||||
@@ -215,6 +219,7 @@ function backToProjects() {
|
||||
}
|
||||
|
||||
function handleFileSelect(filePath: string) {
|
||||
toggleFileSelection(filePath)
|
||||
openFile(filePath)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user