diff --git a/packages/app/scripts/generate-seed-chats.ts b/packages/app/scripts/generate-seed-chats.ts new file mode 100644 index 00000000..0f4c98d6 --- /dev/null +++ b/packages/app/scripts/generate-seed-chats.ts @@ -0,0 +1,21 @@ +/** + * Generate .dev/chats.json from the seed prompt index. + * Run: npx tsx packages/app/scripts/generate-seed-chats.ts + */ +import { seedPromptsToConversations } from '../src/__tests__/fixtures/seedPrompts' +import { writeFileSync, mkdirSync, existsSync } from 'fs' +import { resolve, dirname } from 'path' + +const outPath = resolve(dirname(new URL(import.meta.url).pathname), '../.dev/chats.json') +const dir = dirname(outPath) +if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + +const conversations = seedPromptsToConversations() +const ids = Object.keys(conversations) +const data = { + conversations, + activeConversationId: ids[0] ?? null, +} + +writeFileSync(outPath, JSON.stringify(data, null, 2), 'utf-8') +console.log(`Wrote ${ids.length} seed conversations to ${outPath}`) diff --git a/packages/app/src/App.vue b/packages/app/src/App.vue index ad7eecd0..282b100b 100644 --- a/packages/app/src/App.vue +++ b/packages/app/src/App.vue @@ -2,6 +2,7 @@
+ any>( + fn: T, + delay: number, +): { (...args: Parameters): void; cancel: () => void } { + let timer: ReturnType | null = null + + const debounced = (...args: Parameters) => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + fn(...args) + timer = null + }, delay) + } + + debounced.cancel = () => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + + return debounced +} +\`\`\` + +Usage example: + +\`\`\`typescript +const debouncedSearch = debounce((query: string) => { + console.log('Searching for:', query) + fetch(\`/api/search?q=\${encodeURIComponent(query)}\`) +}, 300) + +// In an input handler: +input.addEventListener('input', (e) => { + debouncedSearch((e.target as HTMLInputElement).value) +}) + +// Cleanup: +debouncedSearch.cancel() +\`\`\` + +Key design decisions: +- **Generic types** preserve the original function's parameter types +- **Cancel method** prevents stale calls on component unmount +- **ReturnType** works in both Node and browser +- The return type explicitly shows the augmented function signature`, + expected: { codeBlocks: 2 }, + }, + + // ─── Images ───────────────────────────────────────────────── + { + id: 'seed-images', + types: ['images'], + userQuery: 'Show me some beautiful landscape photography', + assistantResponse: `Here are stunning landscape photographs: + +![Patagonia's Torres del Paine at sunrise](https://images.unsplash.com/photo-1531804055935-76f44d7c3621?w=1200) + +The granite towers catching first light create one of Earth's most dramatic mountain scenes. + +![Iceland's Kirkjufell mountain with aurora borealis](https://images.unsplash.com/photo-1504829857797-ddff29c27927?w=1200) + +The iconic arrowhead-shaped mountain with northern lights dancing above. + +![Japanese autumn — Kiyomizu-dera temple in Kyoto](https://images.unsplash.com/photo-1493976040374-85c8e12f0c0e?w=1200) + +Crimson maples frame the famous wooden stage during peak koyo season. + +These represent three different approaches to landscape photography: dramatic alpine, astrophotography, and cultural landscape. Each uses natural light and careful composition to tell a story about place.`, + expected: { images: 3 }, + }, + + // ─── Mixed: Films + Songs ─────────────────────────────────── + { + id: 'seed-mixed-films-songs', + types: ['films', 'songs'], + userQuery: 'What are the best movie soundtracks?', + assistantResponse: `Great question — here are films with legendary soundtracks: + +[[film_ext:Drive|2011|Nicolas Winding Refn]] — The synthwave soundtrack defined a genre. Cliff Martinez's score plus curated tracks create an unforgettable atmosphere. + +Key tracks: +[[song_ext:Nightcall|Kavinsky|2010]] — The opening drive through LA at night. Pure 80s-inspired synth perfection. +[[song_ext:A Real Hero|College & Electric Youth|2010]] — The emotional core of the film. Dreamy, melancholic electronica. + +[[film_ext:Blade Runner 2049|2017|Denis Villeneuve]] — Hans Zimmer and Benjamin Wallfisch created a soundscape that honors Vangelis while pushing into new territory. + +[[film_ext:Interstellar|2014|Christopher Nolan]] — Zimmer's organ-driven score is overwhelming in IMAX. "No Time for Caution" during the docking scene is peak cinema. + +[[song_ext:Time|Hans Zimmer|2010]] — From Inception. The building layers of strings and brass have become the template for modern trailer music. + +[[song_ext:Where Is My Mind?|Pixies|1988]] — The Fight Club ending. Sometimes the perfect needle drop is worth more than any original score. + +The best soundtracks don't just accompany the film — they become inseparable from the experience.`, + expected: { films: 3, songs: 2 }, + }, + + // ─── Mixed: Books + Podcasts ──────────────────────────────── + { + id: 'seed-mixed-books-podcasts', + types: ['books', 'podcasts'], + userQuery: 'I want to learn about Austrian economics, what should I read and listen to?', + assistantResponse: `Here's a complete Austrian economics learning path: + +## Essential Reading + +[[book_ext:Economics in One Lesson|Henry Hazlitt|1946]] — The best starting point. Hazlitt distills Bastiat's "seen and unseen" into a masterclass on economic thinking. Short, clear, devastating to interventionist arguments. + +[[book_ext:Human Action|Ludwig von Mises|1949]] — The magnum opus of Austrian economics. Dense but comprehensive — covers praxeology, money, banking, and the business cycle. Read after Hazlitt. + +[[book_ext:The Road to Serfdom|Friedrich Hayek|1944]] — Hayek's warning about central planning. Written during WWII, prophetic about the dangers of collectivism. Surprisingly readable. + +[[book_ext:Man, Economy, and State|Murray Rothbard|1962]] — Rothbard's systematic treatise building from first principles. More accessible than Mises, more rigorous than Hazlitt. + +## Podcasts for Ongoing Learning + +[[podcast_ext:The Tom Woods Show|Tom Woods|2013]] — Daily episodes on economics, liberty, and history. Woods makes Austrian concepts accessible with humor and clarity. + +[[podcast_ext:Human Action Podcast|Jeff Deist|2018]] — Chapter-by-chapter walkthrough of Mises' Human Action. Perfect companion to the book. + +[[podcast_ext:EconTalk|Russ Roberts|2006]] — Not strictly Austrian, but Roberts' classical liberal perspective and interview style make this essential economics listening. + +**Path:** Hazlitt → Tom Woods podcast → Road to Serfdom → Human Action book + podcast → Rothbard for depth.`, + expected: { books: 4, podcasts: 3 }, + }, + + // ─── Magazine / News ──────────────────────────────────────── + { + id: 'seed-magazine', + types: ['magazine'], + userQuery: "What's happening in the Bitcoin ecosystem this week?", + assistantResponse: `## Bitcoin Weekly Roundup + +### Lightning Network Milestone +The Lightning Network reached 6,000 BTC in public capacity this week, a new all-time high. Channel count grew 12% month-over-month, with major growth in developing markets. + +**amboss.space** — Track Lightning network statistics and node rankings. + +--- + +### Ordinals Debate Heats Up +Bitcoin Core developers are split on whether to filter Ordinals inscriptions at the mempool level. Luke Dashjr's patch was rejected, but the debate continues about block space allocation. + +**delvingbitcoin.org** — Developer discussion forum where the technical arguments play out. + +--- + +### Regulatory Update: EU MiCA +The EU's Markets in Crypto-Assets regulation takes effect next month. Self-custody remains legal, but exchanges face new reporting requirements. Most Bitcoin-only companies are prepared. + +--- + +### Mining Difficulty Adjustment +Difficulty increased 4.7% — the third consecutive positive adjustment. Hash rate continues climbing post-halving as miners optimize operations. + +**mempool.space** — Real-time mempool and mining statistics. + +--- + +*Sources: Bitcoin Magazine, Nostr, Lightning Labs blog*`, + expected: { magazineSections: 4 }, + }, + + // ─── Recipes ──────────────────────────────────────────────── + { + id: 'seed-recipes', + types: ['recipes'], + userQuery: 'How do I make a proper Italian carbonara?', + assistantResponse: `Here's an authentic Roman carbonara — no cream, no shortcuts: + + +- 400g spaghetti or rigatoni +- 200g guanciale (cured pork cheek), cut into strips +- 4 large egg yolks + 2 whole eggs +- 100g Pecorino Romano, finely grated +- Freshly ground black pepper +1. Bring a large pot of well-salted water to boil. Cook pasta 1 minute short of al dente. +2. Cut guanciale into 1cm strips. Cook in a cold dry pan over medium heat until fat renders and edges crisp (8-10 min). Remove from heat. +3. Whisk egg yolks, whole eggs, and most of the Pecorino together. Season generously with black pepper. +4. When pasta is ready, reserve 200ml pasta water. Drain and add pasta to the guanciale pan (off heat). +5. Wait 30 seconds for the pan to cool slightly, then pour the egg mixture over the pasta. Toss vigorously — the residual heat cooks the eggs into a creamy sauce. Add pasta water a splash at a time if needed. +6. Serve immediately with remaining Pecorino and more black pepper. + + +**Keys to success:** +- Guanciale, not pancetta or bacon — the flavor is completely different +- Pan OFF heat before adding eggs — scrambled eggs ≠ carbonara +- Pecorino Romano, not Parmesan — sharper, saltier, authentic +- Save that pasta water — it's liquid gold for emulsifying the sauce`, + expected: { recipes: 1 }, + }, + + // ─── Events ───────────────────────────────────────────────── + { + id: 'seed-events', + types: ['events'], + userQuery: 'What Bitcoin conferences are coming up?', + assistantResponse: `Here are the major upcoming Bitcoin events: + + +The largest annual Bitcoin conference. 35,000+ attendees, major announcements, and networking. Past speakers include Saylor, Dorsey, and Bukele. + + + +The OG Bitcoin-only conference. Technical focus, cypherpunk ethos, and intimate atmosphere. 1,000 attendees who actually build things. + + + +Focused on real-world Bitcoin adoption. Lightning Network workshops, merchant onboarding, and stories from the ground in El Salvador. + + + +Developer-focused unconference. Hands-on workshops for building on Bitcoin and Lightning. Small, technical, high signal. + + +**Tip:** Baltic Honeybadger sells out fast. TABConf is the best value for developers. Bitcoin 2026 is the big tent event.`, + expected: { events: 4 }, + }, + + // ─── TV + Places combined ─────────────────────────────────── + { + id: 'seed-mixed-tv-places', + types: ['tvSeries', 'places'], + userQuery: 'Best food shows and where to eat what they feature', + assistantResponse: `Food TV worth watching, with real-world destinations: + +[[tv_ext:The Bear|Christopher Storer|2022–present]] — Set in a Chicago Italian beef shop. The kitchen chaos is real — watch it for the cooking AND the anxiety. + +Eat the real thing at: +[[place_ext:Mr. Beef|Italian Beef|Chicago IL|4.2|1|666 N Orleans St]] — The actual shop that inspired The Bear. Cash only. Get the Italian beef dipped with hot giardiniera. + +[[tv_ext:Chef's Table|David Gelb|2015–present]] — Netflix's cinematic exploration of the world's best chefs. Each episode is a visual feast. + +Featured restaurant still taking reservations: +[[place_ext:Gaggan Anand|Indian|Bangkok|4.9|4|68/1 Soi Langsuan]] — The molecular Indian cuisine from Chef's Table Season 2. 25-course tasting menu. + +[[tv_ext:Anthony Bourdain: Parts Unknown|Zero Point Zero|2013–2018]] — Bourdain's masterpiece. Food, culture, and humanity. The Japan and Iran episodes are peak television. + +Bourdain's favorite: +[[place_ext:Bún Bò Huế Bà Phượng|Vietnamese|Huế Vietnam|4.5|1|Huế City]] — The noodle soup spot from the Vietnam episode. Simple, perfect, $2 a bowl.`, + expected: { tvSeries: 3, places: 3 }, + }, + + // ─── Tag stripping test — all tags in one response ────────── + { + id: 'seed-strip-tags', + types: ['films', 'songs', 'books', 'tvSeries', 'places', 'podcasts'], + userQuery: 'Give me your top pick in every category', + assistantResponse: `Here's one masterpiece per category: + +**Film:** [[film_ext:There Will Be Blood|2007|Paul Thomas Anderson]] — Daniel Day-Lewis as an oil prospector consumed by greed. The milkshake scene. The bowling alley. Perfect. + +**Song:** [[song_ext:Bohemian Rhapsody|Queen|1975]] — Six minutes that redefined what a pop single could be. Opera section? Guitar solo? Headbanging? Yes to all. + +**Book:** [[book_ext:Blood Meridian|Cormac McCarthy|1985]] — The darkest, most beautiful novel in American literature. The Judge is literature's greatest villain. + +**TV Show:** [[tv_ext:The Wire|David Simon|2002–2008]] — Every institution fails. Every character is compromised. Baltimore becomes a lens for all of America. + +**Restaurant:** [[place_ext:Jiro Sushi|Sushi|Tokyo|4.9|4|Ginza]] — 20 pieces of sushi. No menu. The greatest craftsman alive serves fish that transcends food. + +**Podcast:** [[podcast_ext:Hardcore History|Dan Carlin|2006]] — Multi-hour epics on history's most dramatic moments. "Blueprint for Armageddon" (WWI) is the greatest podcast ever made. + +One of each is all you need to start.`, + expected: { films: 1, songs: 1, books: 1, tvSeries: 1, places: 1, podcasts: 1 }, + }, +] + +/** + * Convert seed prompts to the .dev/chats.json conversation format. + */ +export function seedPromptsToConversations(): Record { + const conversations: Record = {} + const baseTime = 1772488800000 // Stable base timestamp + + for (let i = 0; i < seedPrompts.length; i++) { + const seed = seedPrompts[i] + const ts = baseTime + i * 60000 + conversations[seed.id] = { + id: seed.id, + title: seed.userQuery.slice(0, 60), + messages: [ + { id: `${seed.id}-q`, role: 'user', content: seed.userQuery, timestamp: ts }, + { id: `${seed.id}-a`, role: 'assistant', content: seed.assistantResponse, timestamp: ts + 3000 }, + ], + createdAt: ts, + updatedAt: ts + 3000, + } + } + + return conversations +} diff --git a/packages/app/src/__tests__/seedExtraction.test.ts b/packages/app/src/__tests__/seedExtraction.test.ts new file mode 100644 index 00000000..24baa863 --- /dev/null +++ b/packages/app/src/__tests__/seedExtraction.test.ts @@ -0,0 +1,239 @@ +/** + * Seed Extraction Tests + * + * Validates that every seed prompt in the prompt index extracts the expected + * content types and counts. These are the gold-standard test cases — if any + * fail, the content surfacing pipeline has regressed. + * + * Run overnight to harden extraction patterns against real-world AI responses. + */ +import { describe, it, expect } from 'vitest' +import { seedPrompts } from './fixtures/seedPrompts' +import { + extractAllFilms, + extractAllSongs, + extractAllPodcasts, + extractAllBooks, + extractAllTVSeries, + extractAllPlaces, + extractAllImages, + extractCodeBlocks, + extractRecipes, + extractEvents, + extractMagazineSections, + stripContentTags, + stripRecipeTags, + stripEventTags, +} from '@/composables/contentExtraction' + +// ─── Extraction count validation ───────────────────────────── + +describe('Seed prompt extraction', () => { + for (const seed of seedPrompts) { + describe(`[${seed.id}] "${seed.userQuery}"`, () => { + const text = seed.assistantResponse + const query = seed.userQuery + + if (seed.expected.films !== undefined) { + it(`extracts ${seed.expected.films} films`, () => { + const films = extractAllFilms(text) + expect(films.length).toBe(seed.expected.films) + for (const f of films) { + expect(f.title).toBeTruthy() + } + }) + } + + if (seed.expected.songs !== undefined) { + it(`extracts ${seed.expected.songs} songs`, () => { + const songs = extractAllSongs(text, query) + expect(songs.length).toBe(seed.expected.songs) + for (const s of songs) { + expect(s.title).toBeTruthy() + expect(s.artist).toBeTruthy() + } + }) + } + + if (seed.expected.books !== undefined) { + it(`extracts ${seed.expected.books} books`, () => { + const books = extractAllBooks(text, query) + expect(books.length).toBe(seed.expected.books) + for (const b of books) { + expect(b.title).toBeTruthy() + } + }) + } + + if (seed.expected.tvSeries !== undefined) { + it(`extracts ${seed.expected.tvSeries} TV series`, () => { + const tv = extractAllTVSeries(text, query) + expect(tv.length).toBe(seed.expected.tvSeries) + for (const t of tv) { + expect(t.title).toBeTruthy() + } + }) + } + + if (seed.expected.places !== undefined) { + it(`extracts ${seed.expected.places} places`, () => { + const places = extractAllPlaces(text, query) + expect(places.length).toBe(seed.expected.places) + for (const p of places) { + expect(p.name).toBeTruthy() + } + }) + } + + if (seed.expected.podcasts !== undefined) { + it(`extracts ${seed.expected.podcasts} podcasts`, () => { + const podcasts = extractAllPodcasts(text) + expect(podcasts.length).toBe(seed.expected.podcasts) + for (const p of podcasts) { + expect(p.title).toBeTruthy() + } + }) + } + + if (seed.expected.images !== undefined) { + it(`extracts ${seed.expected.images} images`, () => { + const images = extractAllImages(text, query) + expect(images.length).toBe(seed.expected.images) + }) + } + + if (seed.expected.codeBlocks !== undefined) { + it(`extracts ${seed.expected.codeBlocks} code blocks`, () => { + const code = extractCodeBlocks(text) + expect(code.length).toBe(seed.expected.codeBlocks) + for (const c of code) { + expect(c.code.trim()).toBeTruthy() + } + }) + } + + if (seed.expected.recipes !== undefined) { + it(`extracts ${seed.expected.recipes} recipes`, () => { + const recipes = extractRecipes(text) + expect(recipes.length).toBe(seed.expected.recipes) + for (const r of recipes) { + expect(r.title).toBeTruthy() + expect(r.ingredients.length).toBeGreaterThan(0) + expect(r.steps.length).toBeGreaterThan(0) + } + }) + } + + if (seed.expected.events !== undefined) { + it(`extracts ${seed.expected.events} events`, () => { + const events = extractEvents(text) + expect(events.length).toBe(seed.expected.events) + for (const e of events) { + expect(e.title).toBeTruthy() + } + }) + } + + if (seed.expected.magazineSections !== undefined) { + it(`extracts ${seed.expected.magazineSections} magazine sections`, () => { + const sections = extractMagazineSections(text) + expect(sections.length).toBeGreaterThanOrEqual(seed.expected.magazineSections!) + }) + } + }) + } +}) + +// ─── Tag stripping — no tags leak into displayed content ────── + +describe('Tag stripping completeness', () => { + for (const seed of seedPrompts) { + it(`[${seed.id}] stripContentTags removes all bracket tags`, () => { + const cleaned = stripContentTags(seed.assistantResponse) + // No [[...]] bracket tags should remain + const bracketMatches = cleaned.match(/\[\[[^\]]+\]\]/g) + expect(bracketMatches).toBeNull() + }) + + it(`[${seed.id}] strip functions remove all XML tags`, () => { + let cleaned = stripRecipeTags(stripEventTags(seed.assistantResponse)) + cleaned = stripContentTags(cleaned) + // No <..._ext> XML tags should remain + const xmlMatches = cleaned.match(/<\/?(?:recipe|event)_ext[^>]*>/g) + expect(xmlMatches).toBeNull() + }) + } +}) + +// ─── Data integrity — extracted content has required fields ─── + +describe('Extraction data integrity', () => { + const filmSeed = seedPrompts.find(s => s.id === 'seed-films')! + it('films have title, year, and director', () => { + const films = extractAllFilms(filmSeed.assistantResponse) + for (const f of films) { + expect(f.title).toBeTruthy() + expect(f.year).toBeGreaterThan(1900) + expect(f.director).toBeTruthy() + } + }) + + const songSeed = seedPrompts.find(s => s.id === 'seed-songs')! + it('songs have title, artist, and year', () => { + const songs = extractAllSongs(songSeed.assistantResponse, songSeed.userQuery) + for (const s of songs) { + expect(s.title).toBeTruthy() + expect(s.artist).toBeTruthy() + } + }) + + const bookSeed = seedPrompts.find(s => s.id === 'seed-books')! + it('books have title and author', () => { + const books = extractAllBooks(bookSeed.assistantResponse, bookSeed.userQuery) + for (const b of books) { + expect(b.title).toBeTruthy() + expect(b.author).toBeTruthy() + } + }) + + const tvSeed = seedPrompts.find(s => s.id === 'seed-tv')! + it('TV series have title and creator', () => { + const tv = extractAllTVSeries(tvSeed.assistantResponse, tvSeed.userQuery) + for (const t of tv) { + expect(t.title).toBeTruthy() + expect(t.creator).toBeTruthy() + } + }) + + const placeSeed = seedPrompts.find(s => s.id === 'seed-places')! + it('places have name, cuisine, and city', () => { + const places = extractAllPlaces(placeSeed.assistantResponse, placeSeed.userQuery) + for (const p of places) { + expect(p.name).toBeTruthy() + expect(p.cuisine).toBeTruthy() + expect(p.city).toBeTruthy() + } + }) + + const recipeSeed = seedPrompts.find(s => s.id === 'seed-recipes')! + it('recipes have complete data', () => { + const recipes = extractRecipes(recipeSeed.assistantResponse) + expect(recipes.length).toBe(1) + const r = recipes[0] + expect(r.title).toBe('Spaghetti alla Carbonara') + expect(r.servings).toBe('4') + expect(r.time).toBe('25 min') + expect(r.ingredients.length).toBeGreaterThanOrEqual(4) + expect(r.steps.length).toBeGreaterThanOrEqual(5) + }) + + const eventSeed = seedPrompts.find(s => s.id === 'seed-events')! + it('events have title, date, and location', () => { + const events = extractEvents(eventSeed.assistantResponse) + for (const e of events) { + expect(e.title).toBeTruthy() + expect(e.date).toBeTruthy() + expect(e.location).toBeTruthy() + } + }) +}) diff --git a/packages/app/src/components/chat/ChatHeader.vue b/packages/app/src/components/chat/ChatHeader.vue index 120ce5d0..fae9ca16 100644 --- a/packages/app/src/components/chat/ChatHeader.vue +++ b/packages/app/src/components/chat/ChatHeader.vue @@ -14,20 +14,6 @@
- -
-

+

{{ currentSong!.title }}

-

+

{{ currentSong!.artist }}

@@ -39,9 +38,7 @@ - + {{ formatTime(currentTime) }}
- + {{ formatTime(duration) }}
@@ -101,15 +93,14 @@ {{ queue.length }} songs @@ -119,11 +110,9 @@