fix(app): harden persistence, player performance, content extraction

- Fix chat persistence: unwrap Vue Proxy objects before IDB storage,
  flush pending saves on page unload/visibility change, use immediate
  saves for conversation creation and seed migration
- Fix player performance: parallel music search across providers,
  server-side LRU cache, client-side result cache, audio element reuse,
  instant UI feedback, abort stale searches, next-song prefetch
- Fix content extraction: strip recipe/event tags in stripContentTags,
  extend cleanMagazineContent regex for all _ext patterns
- Fix PlayerBar: move to App.vue root to avoid stacking context clipping,
  remove unused isDark conditionals (dark-only app)
- Add /seed command: loads 15 seed conversations from fixture index,
  opens history panel, switches to first seed conversation
- Add seed prompt index: 15 realistic AI prompt/response pairs covering
  films, songs, books, TV, places, podcasts, code, images, recipes, events
- Add seedExtraction.test.ts: 60 tests validating extraction counts,
  tag stripping completeness, and data integrity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 17:34:21 +00:00
co-authored by Claude Opus 4.6
parent 6c29e9e41d
commit 493657549e
15 changed files with 1093 additions and 153 deletions
@@ -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}`)
+2
View File
@@ -2,6 +2,7 @@
<div class="h-dvh flex flex-col" :class="currentTheme">
<RouterView />
<ArticleOverlay />
<PlayerBar />
<PassphraseDialog
:visible="showPassphrase"
:is-creating="isCreatingPassphrase"
@@ -17,6 +18,7 @@ import { RouterView } from 'vue-router'
import { useTheme } from '@/composables/useTheme'
import { useArchy } from '@/composables/useArchy'
import ArticleOverlay from '@/components/content/ArticleOverlay.vue'
import PlayerBar from '@/components/player/PlayerBar.vue'
import PassphraseDialog from '@/components/ui/PassphraseDialog.vue'
import {
isCryptoEnabled,
@@ -0,0 +1,467 @@
/**
* Seed Prompt Index — realistic AI prompt/response pairs covering every content type.
* Used by: .dev/chats.json (seeded conversations), extraction tests, e2e tests.
*
* Each entry represents a user query + AI response that exercises a specific
* content surface (films, songs, books, TV, places, podcasts, images, code,
* recipes, events, news/magazine, mixed content).
*/
export interface SeedPrompt {
id: string
/** Content types this prompt exercises */
types: string[]
userQuery: string
assistantResponse: string
/** Expected extraction counts for validation */
expected: {
films?: number
songs?: number
books?: number
tvSeries?: number
places?: number
podcasts?: number
images?: number
codeBlocks?: number
recipes?: number
events?: number
magazineSections?: number
apps?: number
}
}
export const seedPrompts: SeedPrompt[] = [
// ─── Films ──────────────────────────────────────────────────
{
id: 'seed-films',
types: ['films'],
userQuery: 'What are the best Christopher Nolan films?',
assistantResponse: `Here are Christopher Nolan's finest works:
[[film_ext:Inception|2010|Christopher Nolan]] — A mind-bending heist through layers of dreams. DiCaprio leads an ensemble cast through architecture-defying action sequences and emotionally resonant storytelling.
[[film_ext:The Dark Knight|2008|Christopher Nolan]] — Heath Ledger's Joker elevates this beyond superhero fare into genuine crime thriller territory. The interrogation scene alone is worth the watch.
[[film_ext:Interstellar|2014|Christopher Nolan]] — A father-daughter love story wrapped in hard science fiction. The docking scene and "years of messages" sequence are devastating.
[[film_ext:Memento|2000|Christopher Nolan]] — The reverse-chronology thriller that put Nolan on the map. Guy Pearce hunts his wife's killer while unable to form new memories.
[[film_ext:Oppenheimer|2023|Christopher Nolan]] — Cillian Murphy embodies the father of the atomic bomb in Nolan's most dialogue-heavy and politically charged film.
All feature practical effects, IMAX cinematography, and Hans Zimmer scores (except Memento and Oppenheimer's Göransson score).`,
expected: { films: 5 },
},
// ─── Songs / Music ──────────────────────────────────────────
{
id: 'seed-songs',
types: ['songs'],
userQuery: 'Recommend some ambient electronic music for deep work',
assistantResponse: `Here are excellent ambient electronic tracks for focused work:
[[song_ext:Weightless|Marconi Union|2011]] — Scientifically designed to reduce anxiety. The 8-minute track uses sustained harmonies and a gradually slowing tempo (60 BPM down to 50).
[[song_ext:An Ending (Ascent)|Brian Eno|1983]] — From the Apollo album. Pure synthesizer beauty — ascending chords that feel like floating above Earth. 4 minutes of transcendence.
[[song_ext:Intro|The xx|2009]] — Minimalist guitar loops over electronic beats. Not strictly ambient, but the repetitive structure aids concentration.
[[song_ext:Avril 14th|Aphex Twin|2001]] — A rare gentle piano piece from Richard D. James. Delicate and hypnotic — perfect for reading or writing.
[[song_ext:Music for Airports 1/1|Brian Eno|1978]] — The track that coined "ambient music." Tape loops of piano and voices create an ever-evolving soundscape.
[[song_ext:Treefingers|Radiohead|2000]] — From Kid A. Processed guitar creating ethereal textures. Works beautifully on loop.`,
expected: { songs: 10 },
},
// ─── Books ──────────────────────────────────────────────────
{
id: 'seed-books',
types: ['books'],
userQuery: 'Best books on Bitcoin and monetary history',
assistantResponse: `Essential reading on Bitcoin and the history of money:
[[book_ext:The Bitcoin Standard|Saifedean Ammous|2018]] — The foundational text connecting Austrian economics to Bitcoin. Traces money from primitive shells through gold to fiat, making the case for Bitcoin as the hardest money ever created.
[[book_ext:The Fiat Standard|Saifedean Ammous|2021]] — The sequel examining how fiat currency actually works — debt creation, cantillon effects, and why the system incentivizes time preference distortion.
[[book_ext:Mastering Bitcoin|Andreas Antonopoulos|2017]] — The technical reference. Covers cryptography, transactions, mining, and the protocol in depth. Essential for developers.
[[book_ext:The Price of Tomorrow|Jeff Booth|2020]] — Argues that technology is naturally deflationary, but central banks fight this with money printing. Bitcoin aligns with technology's deflationary nature.
[[book_ext:Broken Money|Lyn Alden|2023]] — A comprehensive monetary history from ancient to modern times, examining why monetary systems break and how Bitcoin fits into the broader evolution of money technology.
**Reading order:** Start with *The Bitcoin Standard* for philosophy, then *Broken Money* for history, then *Mastering Bitcoin* for technical depth.`,
expected: { books: 5 },
},
// ─── TV Series ──────────────────────────────────────────────
{
id: 'seed-tv',
types: ['tvSeries'],
userQuery: 'Best TV shows of the last decade',
assistantResponse: `Outstanding television from the 2010s and 2020s:
[[tv_ext:Breaking Bad|Vince Gilligan|20082013]] — Walter White's transformation from chemistry teacher to drug lord. The gold standard of character-driven TV drama with a flawless final season.
[[tv_ext:Severance|Dan Erickson|2022present]] — A workplace thriller where employees surgically separate work and personal memories. Ben Stiller directs a Kafkaesque masterpiece.
[[tv_ext:Chernobyl|Craig Mazin|2019]] — Five episodes covering the 1986 nuclear disaster. Haunting, meticulously researched, and terrifyingly relevant to institutional failure.
[[tv_ext:The Bear|Christopher Storer|2022present]] — A fine-dining chef returns to run his family's Chicago sandwich shop. Captures kitchen intensity with a season 2 that rivals prestige drama.
[[tv_ext:Better Call Saul|Peter Gould|20152022]] — The Breaking Bad prequel that arguably surpasses it. Bob Odenkirk delivers a career-defining performance across 6 seasons.
[[tv_ext:True Detective|Nic Pizzolatto|2014present]] — Season 1 with McConaughey and Harrelson is peak television. Philosophical detective fiction in the Louisiana bayou.
All available on major streaming platforms. Start with whichever genre appeals most — there's no wrong entry point.`,
expected: { tvSeries: 6 },
},
// ─── Places / Restaurants ───────────────────────────────────
{
id: 'seed-places',
types: ['places'],
userQuery: 'Best restaurants in Austin Texas',
assistantResponse: `Here are Austin's standout restaurants:
[[place_ext:Franklin Barbecue|BBQ|Austin TX|4.8|2|1900 E 11th St]] — The most famous BBQ in Texas. Expect 3-4 hour lines for brisket that's genuinely worth the wait. Get there by 8am on weekends.
[[place_ext:Uchi|Japanese|Austin TX|4.7|3|801 S Lamar Blvd]] — Tyson Cole's Japanese farmhouse dining. The tasting menu is exceptional — the maguro sashimi with goat cheese and cracked pepper is iconic Austin dining.
[[place_ext:Ramen Tatsu-Ya|Ramen|Austin TX|4.6|2|8557 Research Blvd]] — Austin's best ramen. The Underbelly (pork broth, woodear mushroom, nori) has cult status. Cash only, expect a line.
[[place_ext:Suerte|Mexican|Austin TX|4.5|2|1800 E 6th St]] — Modern Mexican from the Uchi team. Smoked beef suadero tacos and duck carnitas are standouts. Great mezcal program.
[[place_ext:Odd Duck|American|Austin TX|4.4|2|1201 S Lamar Blvd]] — Farm-to-table small plates in a converted trailer park. The menu changes daily based on what's fresh. Excellent craft cocktails.
**Pro tip:** Reservations are essential for Uchi and Suerte. Franklin is walk-in only. Visit Odd Duck for late-night bites after 9pm.`,
expected: { places: 5 },
},
// ─── Podcasts ───────────────────────────────────────────────
{
id: 'seed-podcasts',
types: ['podcasts'],
userQuery: 'Best Bitcoin and technology podcasts',
assistantResponse: `Top podcasts for Bitcoin and tech:
[[podcast_ext:What Bitcoin Did|Peter McCormack|2018]] — Twice-weekly interviews covering Bitcoin from beginner to advanced. Peter's honest, accessible style makes complex topics approachable. Essential listening.
[[podcast_ext:Lex Fridman Podcast|Lex Fridman|2018]] — Long-form conversations with scientists, engineers, and thinkers. The Bitcoin episodes with Saylor, Dorsey, and Antonopoulos are must-listens.
[[podcast_ext:Bitcoin Audible|Guy Swann|2016]] — Guy reads and discusses the best Bitcoin articles and papers. Perfect for commutes — turns written content into audio education.
[[podcast_ext:TFTC - Tales from the Crypt|Marty Bent|2018]] — No-nonsense Bitcoin podcast. Marty's "Rabbit Hole Recap" Friday episodes are a great weekly Bitcoin news summary.
[[podcast_ext:Stephan Livera Podcast|Stephan Livera|2018]] — Technical and economic deep dives. Stephan interviews protocol developers, economists, and Lightning Network builders.
Start with **What Bitcoin Did** for breadth, **Stephan Livera** for depth, and **Lex Fridman** for cross-disciplinary thinking.`,
expected: { podcasts: 5 },
},
// ─── Code ───────────────────────────────────────────────────
{
id: 'seed-code',
types: ['code'],
userQuery: 'Show me how to implement a debounce function in TypeScript',
assistantResponse: `Here's a proper TypeScript debounce with generic types and cancel support:
\`\`\`typescript
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number,
): { (...args: Parameters<T>): void; cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | null = null
const debounced = (...args: Parameters<T>) => {
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<typeof setTimeout>** 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:
<recipe_ext title="Spaghetti alla Carbonara" servings="4" time="25 min" calories="550">
- 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.
</recipe_ext>
**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:
<event_ext title="Bitcoin 2026" date="2026-07-25" location="Nashville, TN" url="https://b.tc/conference">
The largest annual Bitcoin conference. 35,000+ attendees, major announcements, and networking. Past speakers include Saylor, Dorsey, and Bukele.
</event_ext>
<event_ext title="Baltic Honeybadger" date="2026-09-05" location="Riga, Latvia" url="https://baltichoneybadger.com">
The OG Bitcoin-only conference. Technical focus, cypherpunk ethos, and intimate atmosphere. 1,000 attendees who actually build things.
</event_ext>
<event_ext title="Adopting Bitcoin" date="2026-11-15" location="San Salvador, El Salvador" url="https://adoptingbitcoin.org">
Focused on real-world Bitcoin adoption. Lightning Network workshops, merchant onboarding, and stories from the ground in El Salvador.
</event_ext>
<event_ext title="TABConf" date="2026-10-10" location="Atlanta, GA" url="https://tabconf.com">
Developer-focused unconference. Hands-on workshops for building on Bitcoin and Lightning. Small, technical, high signal.
</event_ext>
**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|2022present]] — 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|2015present]] — 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|20132018]] — 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|20022008]] — 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<string, {
id: string
title: string
messages: { id: string; role: string; content: string; timestamp: number }[]
createdAt: number
updatedAt: number
}> {
const conversations: Record<string, any> = {}
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
}
@@ -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()
}
})
})
@@ -14,20 +14,6 @@
<span class="text-base"></span>
</button>
<div class="flex items-center gap-2 shrink-0">
<button
class="touch-target rounded-xl path-glass-icon transition-colors"
:class="webSearchEnabled
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="webSearchEnabled ? 'Web search on' : 'Web search off'"
aria-label="Toggle web search"
@click="chatStore.webSearchEnabled = !chatStore.webSearchEnabled"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button
class="touch-target rounded-xl path-glass-icon transition-colors"
:class="chatStore.showHistory
@@ -296,7 +282,6 @@ function getModelCaps(modelId: string) {
return MODEL_CAPS[modelId] ?? { vision: false, tools: false, longContext: false }
}
const chatStore = useChatStore()
const webSearchEnabled = computed(() => chatStore.webSearchEnabled)
const showModelPicker = ref(false)
const showMenu = ref(false)
const menuTriggerRef = ref<HTMLElement | null>(null)
@@ -77,7 +77,7 @@
</div>
<div
class="rounded-2xl px-4 py-3 flex items-end gap-2 transition-all duration-300"
class="rounded-2xl px-4 py-3 flex items-center gap-2 transition-all duration-300"
:class="[
isCodeMode
? 'bg-accent/15 border border-accent/25 backdrop-blur-xl'
@@ -88,7 +88,7 @@
<!-- Image attach button -->
<button
v-if="!streaming && images.length < MAX_IMAGES"
class="shrink-0 touch-target rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
class="shrink-0 min-w-[44px] min-h-[44px] -my-2 flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
aria-label="Attach image"
@click="openFilePicker"
>
@@ -330,6 +330,17 @@ async function handleSend(text: string, images: ImageAttachment[] = []) {
return
}
if (trimmed === '/seed') {
await chatStore.loadSeedChats()
// Switch to first seed conversation and open history
const firstSeed = chatStore.conversationList.find(c => c.id.startsWith('seed-'))
if (firstSeed) {
chatStore.setActiveConversation(firstSeed.id)
}
chatStore.showHistory = true
return
}
if (trimmed === '/nostr') {
panelOpen.value = true
if (!availableTabs.value.includes('nostr')) {
@@ -99,6 +99,7 @@ const BUILT_IN_COMMANDS: PaletteCommand[] = [
{ id: 'cmd-nostr', slash: '/nostr', title: 'Nostr', preview: 'Browse the Nostr network feed' },
{ id: 'cmd-design', slash: '/design', title: 'Design System', preview: 'Open the design system viewer' },
{ id: 'cmd-search', slash: '/search ', title: 'Search', preview: 'Search your content library' },
{ id: 'cmd-seed', slash: '/seed', title: 'Seed', preview: 'Load seed conversations for all content types' },
]
const props = defineProps<{
@@ -24,11 +24,10 @@
<span v-else class="text-lg">🎵</span>
</div>
<div class="min-w-0">
<p class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
<p class="text-sm font-semibold truncate text-white/90">
{{ currentSong!.title }}
</p>
<p class="text-xs truncate" :class="isDark ? 'text-white/50' : 'text-gray-500'">
<p class="text-xs truncate text-white/50">
{{ currentSong!.artist }}
</p>
</div>
@@ -39,9 +38,7 @@
<!-- Previous button -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center shrink-0 transition-all hover:scale-105 active:scale-95"
:class="hasPrevious
? isDark ? 'text-white/70 hover:text-white/90' : 'text-gray-600 hover:text-gray-800'
: isDark ? 'text-white/20' : 'text-gray-300'"
:class="hasPrevious ? 'text-white/70 hover:text-white/90' : 'text-white/20'"
:disabled="!hasPrevious"
@click="playPrevious"
>
@@ -53,23 +50,21 @@
class="w-12 h-12 rounded-full flex items-center justify-center glass-button shrink-0 transition-all hover:scale-105 active:scale-95"
@click="toggle"
>
<svg v-if="isLoading" class="w-5 h-5 animate-spin" :class="isDark ? 'text-white/90' : 'text-gray-800'" fill="none" viewBox="0 0 24 24">
<svg v-if="isLoading" class="w-5 h-5 animate-spin text-white/90" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<svg v-else-if="isPlaying" class="w-5 h-5" :class="isDark ? 'text-white/90' : 'text-gray-800'" fill="currentColor" viewBox="0 0 24 24">
<svg v-else-if="isPlaying" class="w-5 h-5 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="w-5 h-5" :class="isDark ? 'text-white/90' : 'text-gray-800'" fill="currentColor" viewBox="0 0 24 24">
<svg v-else class="w-5 h-5 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</button>
<!-- Next button -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center shrink-0 transition-all hover:scale-105 active:scale-95"
:class="hasNext
? isDark ? 'text-white/70 hover:text-white/90' : 'text-gray-600 hover:text-gray-800'
: isDark ? 'text-white/20' : 'text-gray-300'"
:class="hasNext ? 'text-white/70 hover:text-white/90' : 'text-white/20'"
:disabled="!hasNext"
@click="playNext"
>
@@ -77,13 +72,11 @@
<path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" />
</svg>
</button>
<span class="text-xs font-mono tabular-nums"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
<span class="text-xs font-mono tabular-nums text-white/40">
{{ formatTime(currentTime) }}
</span>
<div
class="flex-1 h-1.5 rounded-full cursor-pointer group"
:class="isDark ? 'bg-white/15' : 'bg-black/10'"
class="flex-1 h-1.5 rounded-full cursor-pointer group bg-white/15"
@click="onScrubberClick"
>
<div
@@ -91,8 +84,7 @@
:style="{ width: `${progress}%` }"
/>
</div>
<span class="text-xs font-mono tabular-nums"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
<span class="text-xs font-mono tabular-nums text-white/40">
{{ formatTime(duration) }}
</span>
</div>
@@ -101,15 +93,14 @@
<span
v-if="queue.length > 1"
class="text-xs font-mono tabular-nums shrink-0"
:class="isDark ? 'text-white/30' : 'text-gray-400'"
class="text-xs font-mono tabular-nums shrink-0 text-white/30"
>{{ queue.length }} songs</span>
<button
class="w-9 h-9 rounded-xl flex items-center justify-center path-glass-button path-glass-button-sm shrink-0 transition-all hover:scale-105"
class="touch-target rounded-xl path-glass-button path-glass-button-sm shrink-0 transition-all hover:scale-105"
@click="clear"
title="Close player"
>
<svg class="w-4 h-4" :class="isDark ? 'text-white/70' : 'text-gray-600'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -119,11 +110,9 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { usePlayer } from '@/composables/usePlayer'
import { generateSongCoverFallback, fetchMusicCover } from '@/composables/useImageFallback'
import { fetchMusicCover } from '@/composables/useImageFallback'
const { isDark } = useTheme()
const {
currentSong,
hasTrack,
@@ -148,7 +148,7 @@ function addSection(
function cleanMagazineContent(raw: string): string {
return raw
.replace(/\[\[(?:podcast|film|song|book|tvshow|film_ext|song_ext):[^\]]*\]\]/g, '')
.replace(/\[\[(?:podcast|film|song|book|tvshow|podcast_ext|film_ext|song_ext|book_ext|tv_ext|place_ext|recipe_ext|event_ext):[^\]]*\]\]/g, '')
.replace(/^\s*\n---\s*\n?/g, '')
.replace(/\n---\s*$/g, '')
.replace(/\n{3,}/g, '\n\n')
@@ -1160,7 +1160,7 @@ export function stripPlaceTags(text: string): string {
}
export function stripContentTags(text: string): string {
return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(stripTVTags(stripPlaceTags(text))))))
return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(stripTVTags(stripPlaceTags(stripRecipeTags(stripEventTags(text))))))))
}
export function stripMarkdownLinks(text: string): string {
+114 -44
View File
@@ -1,4 +1,4 @@
import { ref, shallowRef, computed } from 'vue'
import { ref, shallowRef, computed, watch } from 'vue'
import type { Song } from '@aiui/core/types/content'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
@@ -11,6 +11,8 @@ interface MusicSearchResult {
artist?: string
}
// ─── Global singleton state ───────────────────────────────────
const currentSong = ref<Song | null>(null)
const playableSource = ref<MusicSearchResult | null>(null)
const isPlaying = ref(false)
@@ -25,6 +27,13 @@ const currentIndex = ref(-1)
let plyrInstance: Plyr | null = null
let containerEl: HTMLDivElement | null = null
let audioEl: HTMLAudioElement | null = null
// Client-side search result cache — avoids re-searching songs
const resultCache = new Map<string, MusicSearchResult | null>()
// Active search abort controller — cancel stale searches on rapid switching
let activeSearchController: AbortController | null = null
export function usePlayer() {
const hasTrack = computed(() => !!currentSong.value)
@@ -33,24 +42,43 @@ export function usePlayer() {
return (currentTime.value / duration.value) * 100
})
// ─── Search with abort + cache ────────────────────────────
async function searchMusic(query: string, title?: string, artist?: string): Promise<MusicSearchResult | null> {
const cacheKey = `${query}|${title ?? ''}|${artist ?? ''}`
const cached = resultCache.get(cacheKey)
if (cached !== undefined) return cached
// Cancel any in-flight search
activeSearchController?.abort()
const controller = new AbortController()
activeSearchController = controller
try {
const params = new URLSearchParams({ q: query })
if (title) params.set('title', title)
if (artist) params.set('artist', artist)
const res = await fetch(`/api/music/search?${params}`)
const res = await fetch(`/api/music/search?${params}`, {
signal: controller.signal,
})
const data = (await res.json()) as MusicSearchResult & { error?: string }
if (data.error || !data.url) {
error.value = data.error ?? 'No playable source found'
resultCache.set(cacheKey, null)
return null
}
return data as MusicSearchResult
const result = data as MusicSearchResult
resultCache.set(cacheKey, result)
return result
} catch (e) {
if ((e as Error).name === 'AbortError') return null
error.value = 'Network error. Is the dev server running?'
return null
}
}
// ─── Container management ─────────────────────────────────
function setContainer(el: HTMLDivElement | null) {
containerEl = el
if (el && playableSource.value) {
@@ -58,22 +86,59 @@ export function usePlayer() {
}
}
// ─── Audio player init — reuses audio element when possible ─
function initPlayer(result: MusicSearchResult) {
if (!containerEl) return
destroyPlayer()
if (result.type === 'stream') {
const audio = document.createElement('audio')
audio.src = result.url
audio.crossOrigin = 'anonymous'
// Reuse existing audio element if we have one — just change src
if (audioEl && plyrInstance) {
audioEl.src = result.url
audioEl.load()
Promise.resolve(plyrInstance.play()).catch(() => { /* autoplay blocked */ })
return
}
// First time — create audio element and Plyr
destroyPlayer()
audioEl = document.createElement('audio')
audioEl.src = result.url
audioEl.crossOrigin = 'anonymous'
audioEl.preload = 'auto'
containerEl.innerHTML = ''
containerEl.appendChild(audio)
plyrInstance = new Plyr(audio, {
containerEl.appendChild(audioEl)
plyrInstance = new Plyr(audioEl, {
controls: [],
autoplay: true,
muted: false,
})
plyrInstance.on('ready', () => {
Promise.resolve(plyrInstance!.play()).catch(() => { /* autoplay blocked */ })
})
plyrInstance.on('timeupdate', () => {
currentTime.value = plyrInstance!.currentTime ?? 0
})
plyrInstance.on('loadedmetadata', () => {
duration.value = plyrInstance!.duration ?? 0
})
plyrInstance.on('ended', () => {
isPlaying.value = false
if (currentIndex.value >= 0 && currentIndex.value < queue.value.length - 1) {
playNext()
}
})
plyrInstance.on('playing', () => {
isPlaying.value = true
isLoading.value = false
})
plyrInstance.on('pause', () => {
isPlaying.value = false
})
} else {
// Embed (Odysee etc.) — must recreate iframe
destroyPlayer()
const iframe = document.createElement('iframe')
iframe.src = result.url
iframe.style.width = '100%'
@@ -82,33 +147,10 @@ export function usePlayer() {
containerEl.innerHTML = ''
containerEl.appendChild(iframe)
plyrInstance = null
audioEl = null
duration.value = 0
currentTime.value = 0
return
}
plyrInstance!.on('ready', () => {
Promise.resolve(plyrInstance!.play()).catch(() => { /* autoplay blocked */ })
})
plyrInstance!.on('timeupdate', () => {
currentTime.value = plyrInstance!.currentTime ?? 0
})
plyrInstance!.on('loadedmetadata', () => {
duration.value = plyrInstance!.duration ?? 0
})
plyrInstance!.on('ended', () => {
isPlaying.value = false
// Auto-advance to next song in queue
if (currentIndex.value >= 0 && currentIndex.value < queue.value.length - 1) {
playNext()
}
})
plyrInstance!.on('playing', () => {
isPlaying.value = true
})
plyrInstance!.on('pause', () => {
isPlaying.value = false
})
}
function destroyPlayer() {
@@ -116,25 +158,41 @@ export function usePlayer() {
plyrInstance.destroy()
plyrInstance = null
}
audioEl = null
if (containerEl) {
containerEl.innerHTML = ''
}
}
// ─── Play — instant feedback, then load ───────────────────
async function play(song: Song) {
error.value = null
// Resume if same song
if (currentSong.value?.id === song.id && playableSource.value) {
plyrInstance?.play()
isPlaying.value = true
if (plyrInstance) {
Promise.resolve(plyrInstance.play()).catch(() => {})
isPlaying.value = true
}
return
}
// Set song immediately for instant UI feedback
currentSong.value = song
isLoading.value = true
currentTime.value = 0
duration.value = 0
const query = `${song.title} ${song.artist}`.trim()
const result = await searchMusic(query, song.title, song.artist)
// Guard: user may have switched to a different song while we were searching
if (currentSong.value?.id !== song.id) return
isLoading.value = false
currentSong.value = song
if (!result) {
if (!error.value) error.value = 'No playable source. Tries: Internet Archive, Jamendo, Odysee. Add JAMENDO_CLIENT_ID for more.'
if (!error.value) error.value = 'No playable source. Tried: Internet Archive, Jamendo, Odysee.'
return
}
error.value = null
@@ -150,9 +208,10 @@ export function usePlayer() {
}
function toggle() {
if (isLoading.value) return
if (plyrInstance) {
if (isPlaying.value) pause()
else plyrInstance.play()
else Promise.resolve(plyrInstance.play()).catch(() => {})
}
}
@@ -163,6 +222,8 @@ export function usePlayer() {
currentTime.value = time
}
// ─── Queue management ─────────────────────────────────────
function addToQueue(song: Song) {
const existing = queue.value.find(s => s.id === song.id)
if (!existing) {
@@ -192,19 +253,14 @@ export function usePlayer() {
const newQueue = [...queue.value]
newQueue.splice(index, 1)
queue.value = newQueue
// Adjust currentIndex if needed
if (index < currentIndex.value) {
currentIndex.value--
} else if (index === currentIndex.value) {
// Removed the current track — pause
currentIndex.value = Math.min(currentIndex.value, newQueue.length - 1)
}
}
// Override play to update queue position
const originalPlay = play
async function playWithQueue(song: Song) {
// If not in queue, add it
const idx = queue.value.findIndex(s => s.id === song.id)
if (idx === -1) {
addToQueue(song)
@@ -212,9 +268,23 @@ export function usePlayer() {
} else {
currentIndex.value = idx
}
await originalPlay(song)
await play(song)
}
// ─── Prefetch next song in queue ──────────────────────────
watch(currentIndex, (idx) => {
const nextIdx = idx + 1
if (nextIdx < queue.value.length) {
const next = queue.value[nextIdx]
const query = `${next.title} ${next.artist}`.trim()
// Fire-and-forget — populates the cache
searchMusic(query, next.title, next.artist)
}
})
// ─── Cleanup ──────────────────────────────────────────────
function clear() {
pause()
destroyPlayer()
+1 -7
View File
@@ -1,9 +1,7 @@
<template>
<div
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300 transition-[padding]"
:class="[
hasTrack && 'pb-[72px]'
]"
:class="[]"
:style="isEmbedded
? { background: 'transparent' }
: isDark
@@ -359,7 +357,6 @@
</div>
</div>
<PlayerBar v-if="!isEmbedded" />
</div>
</template>
@@ -374,14 +371,11 @@ import ContentGridView from '@/components/content/ContentGridView.vue'
import DetailView from '@/components/content/DetailView.vue'
import CloseButton from '@/components/content/CloseButton.vue'
import ContextLoader from '@/components/content/ContextLoader.vue'
import PlayerBar from '@/components/player/PlayerBar.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import { usePlayer } from '@/composables/usePlayer'
import { useCodeContext } from '@/composables/useCodeContext'
const chatStore = useChatStore()
const { activeFile: codeActiveFile, isCodeMode, exitCodeMode, clearActiveFile: clearCodeFile } = useCodeContext()
const { hasTrack } = usePlayer()
const { isDark } = useTheme()
// Detect if running inside Archy's iframe — transparent bg, hide player bar
+93 -17
View File
@@ -22,10 +22,12 @@ import {
const isDev = import.meta.env.DEV
const useIDB = isIDBAvailable()
let saveTimer: ReturnType<typeof setTimeout> | null = null
const SAVE_DEBOUNCE = 800
// ─── Dev-chats server middleware (file-based fallback) ────────
let _loaded = false
let devSaveTimer: ReturnType<typeof setTimeout> | null = null
const DEV_SAVE_DEBOUNCE = 800
// Server-side persistence via Vite dev middleware (fallback for dev mode without IDB)
async function loadServerChats(): Promise<{ conversations: Map<string, Conversation>; activeId: string | null }> {
const empty = { conversations: new Map<string, Conversation>(), activeId: null }
if (!isDev) return empty
@@ -47,34 +49,75 @@ async function loadServerChats(): Promise<{ conversations: Map<string, Conversat
}
}
let _loaded = false
function saveServerChats(conversations: Map<string, Conversation>, activeId: string | null) {
if (!isDev || !_loaded) return
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
if (devSaveTimer) clearTimeout(devSaveTimer)
devSaveTimer = setTimeout(() => {
const obj = Object.fromEntries(conversations)
fetch('/api/dev-chats', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversations: obj, activeConversationId: activeId }),
}).catch(() => {})
}, SAVE_DEBOUNCE)
}, DEV_SAVE_DEBOUNCE)
}
// Debounced IDB save for a single conversation
// ─── IDB save with debounce + flush-on-unload ────────────────
const idbSaveTimers = new Map<string, ReturnType<typeof setTimeout>>()
const pendingSaves = new Map<string, Conversation>()
function debouncedIDBSave(conv: Conversation) {
if (!useIDB) return
pendingSaves.set(conv.id, conv)
const existing = idbSaveTimers.get(conv.id)
if (existing) clearTimeout(existing)
idbSaveTimers.set(conv.id, setTimeout(() => {
idbSave(conv).catch(() => {})
idbSave(conv).then(() => {
pendingSaves.delete(conv.id)
}).catch((err) => {
console.warn('[chat] IDB save failed:', err)
})
idbSaveTimers.delete(conv.id)
}, SAVE_DEBOUNCE))
}, 800))
}
/** Immediately save a conversation to IDB (no debounce). */
function immediateIDBSave(conv: Conversation) {
if (!useIDB) return
pendingSaves.delete(conv.id)
const existing = idbSaveTimers.get(conv.id)
if (existing) clearTimeout(existing)
idbSaveTimers.delete(conv.id)
idbSave(conv).catch((err) => {
console.warn('[chat] IDB save failed:', err)
})
}
/** Flush all pending debounced saves — called on page unload. */
function flushPendingSaves() {
for (const [id, timer] of idbSaveTimers) {
clearTimeout(timer)
idbSaveTimers.delete(id)
}
for (const [id, conv] of pendingSaves) {
idbSave(conv).catch(() => {})
pendingSaves.delete(id)
}
}
// Flush pending saves before the page unloads
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flushPendingSaves)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flushPendingSaves()
}
})
}
// ─── Store definition ─────────────────────────────────────────
export const useChatStore = defineStore('chat', () => {
const conversations = ref<Map<string, Conversation>>(new Map())
const activeConversationId = ref<string | null>(null)
@@ -104,11 +147,12 @@ export const useChatStore = defineStore('chat', () => {
_loaded = true
return
}
} catch {
// IDB failed, fall through to dev-chats
} catch (err) {
console.warn('[chat] IDB load failed, falling back:', err)
}
}
// Fall through: IDB empty or unavailable — try dev-chats seed
if (isDev) {
const data = await loadServerChats()
if (data.conversations.size > 0) {
@@ -117,10 +161,10 @@ export const useChatStore = defineStore('chat', () => {
data.activeId && data.conversations.has(data.activeId)
? data.activeId
: [...data.conversations.keys()][0] ?? null
// Migrate existing dev-chats to IndexedDB
// Migrate seed data to IDB immediately (no debounce)
if (useIDB) {
for (const conv of data.conversations.values()) {
idbSave(conv).catch(() => {})
immediateIDBSave(conv)
}
}
}
@@ -181,7 +225,7 @@ export const useChatStore = defineStore('chat', () => {
}
conversations.value.set(id, conversation)
activeConversationId.value = id
debouncedIDBSave(conversation)
immediateIDBSave(conversation)
return id
}
@@ -254,6 +298,7 @@ export const useChatStore = defineStore('chat', () => {
function deleteConversation(id: string) {
conversations.value.delete(id)
if (useIDB) idbDelete(id).catch(() => {})
pendingSaves.delete(id)
if (activeConversationId.value === id) {
const remaining = conversationList.value
activeConversationId.value = remaining.length > 0 ? remaining[0].id : null
@@ -312,7 +357,7 @@ export const useChatStore = defineStore('chat', () => {
}
conversations.value.set(branchId, branchConv)
debouncedIDBSave(branchConv)
immediateIDBSave(branchConv)
// Track branch in parent
if (!conv.childBranchIds) conv.childBranchIds = []
@@ -348,6 +393,36 @@ export const useChatStore = defineStore('chat', () => {
return siblings
}
/** Load seed conversations from fixture index */
async function loadSeedChats(): Promise<number> {
let seedConversations: Map<string, Conversation>
try {
const { seedPromptsToConversations } = await import('@/__tests__/fixtures/seedPrompts')
const raw = seedPromptsToConversations()
seedConversations = new Map(Object.entries(raw)) as Map<string, Conversation>
} catch {
return 0
}
// Build a new Map to guarantee Vue reactivity triggers
const merged = new Map(conversations.value)
let added = 0
for (const [id, conv] of seedConversations) {
if (!merged.has(id)) {
merged.set(id, conv)
immediateIDBSave(conv)
added++
}
}
if (added > 0) {
conversations.value = merged
if (!activeConversationId.value) {
activeConversationId.value = [...seedConversations.keys()][0] ?? null
}
}
return added
}
return {
conversations,
activeConversationId,
@@ -374,5 +449,6 @@ export const useChatStore = defineStore('chat', () => {
deleteMessagesAfter,
branchFromMessage,
getSiblingBranches,
loadSeedChats,
}
})
+11 -1
View File
@@ -5,6 +5,7 @@ import {
encryptToString,
decryptFromString,
} from './crypto'
import { toRaw } from 'vue'
const DB_NAME = 'aiui-store'
const DB_VERSION = 1
@@ -57,9 +58,18 @@ async function decryptConversation(record: EncryptedRecord | Conversation): Prom
}
}
/**
* Deep-unwrap Vue reactive proxies before storing in IDB.
* structuredClone on a Proxy can silently produce empty objects or throw.
*/
function toPlain(conv: Conversation): Conversation {
return JSON.parse(JSON.stringify(toRaw(conv)))
}
export async function saveConversation(conv: Conversation): Promise<void> {
const db = await openDB()
const record = await encryptConversation(conv)
const plain = toPlain(conv)
const record = await encryptConversation(plain)
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(record)
+115 -40
View File
@@ -10,6 +10,36 @@ export interface MusicSearchResult {
artist?: string
}
// ─── Server-side LRU cache ────────────────────────────────────
const CACHE_MAX = 200
const CACHE_TTL = 60 * 60 * 1000 // 1 hour
const searchCache = new Map<string, { result: MusicSearchResult | null; ts: number }>()
function cacheKey(q: string, title?: string, artist?: string): string {
return `${q}|${title ?? ''}|${artist ?? ''}`
}
function getCached(key: string): MusicSearchResult | null | undefined {
const entry = searchCache.get(key)
if (!entry) return undefined
if (Date.now() - entry.ts > CACHE_TTL) {
searchCache.delete(key)
return undefined
}
return entry.result
}
function setCache(key: string, result: MusicSearchResult | null) {
if (searchCache.size >= CACHE_MAX) {
const oldest = searchCache.keys().next().value
if (oldest) searchCache.delete(oldest)
}
searchCache.set(key, { result, ts: Date.now() })
}
// ─── Scoring helpers ──────────────────────────────────────────
function scoreIAResult(doc: { title?: string; creator?: string }, title: string, artist: string): number {
const t = (doc.title ?? '').toLowerCase()
const c = (Array.isArray(doc.creator) ? doc.creator.join(' ') : (doc.creator ?? '')).toLowerCase()
@@ -25,6 +55,41 @@ function scoreIAResult(doc: { title?: string; creator?: string }, title: string,
return score
}
function scoreJamendoTrack(
track: { name: string; artist_name: string },
title: string,
artist: string,
): number {
const t = track.name.toLowerCase()
const a = track.artist_name.toLowerCase()
const titleTerms = title.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
const artistTerms = artist.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
let score = 0
for (const term of titleTerms) {
if (t.includes(term)) score += 2
}
for (const term of artistTerms) {
if (a.includes(term)) score += 2
}
return score
}
function scoreOdyseeItem(name: string, title: string, artist: string): number {
const n = name.toLowerCase().replace(/-/g, ' ')
const titleTerms = title.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
const artistTerms = artist.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
let score = 0
for (const term of titleTerms) {
if (n.includes(term)) score += 2
}
for (const term of artistTerms) {
if (n.includes(term)) score += 2
}
return score
}
// ─── Provider search functions ────────────────────────────────
async function searchInternetArchive(q: string, title?: string, artist?: string): Promise<MusicSearchResult | null> {
try {
const params = new URLSearchParams({
@@ -35,7 +100,7 @@ async function searchInternetArchive(q: string, title?: string, artist?: string)
})
const res = await fetch(
`https://archive.org/advancedsearch.php?${params}`,
{ headers: { Accept: 'application/json' } },
{ headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(8000) },
)
if (!res.ok) return null
const data = (await res.json()) as { response?: { docs?: { identifier: string; title?: string; creator?: string }[] } }
@@ -50,7 +115,9 @@ async function searchInternetArchive(q: string, title?: string, artist?: string)
if (title && artist && scoreIAResult(doc, title, artist) === 0) {
return null
}
const meta = await fetch(`https://archive.org/metadata/${doc.identifier}`)
const meta = await fetch(`https://archive.org/metadata/${doc.identifier}`, {
signal: AbortSignal.timeout(5000),
})
.then((r) => r.json())
.catch(() => null)
const files = (meta as { files?: { name: string; format?: string }[] })?.files ?? []
@@ -73,25 +140,6 @@ async function searchInternetArchive(q: string, title?: string, artist?: string)
}
}
function scoreJamendoTrack(
track: { name: string; artist_name: string },
title: string,
artist: string,
): number {
const t = track.name.toLowerCase()
const a = track.artist_name.toLowerCase()
const titleTerms = title.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
const artistTerms = artist.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
let score = 0
for (const term of titleTerms) {
if (t.includes(term)) score += 2
}
for (const term of artistTerms) {
if (a.includes(term)) score += 2
}
return score
}
async function searchJamendo(q: string, clientId: string, title?: string, artist?: string): Promise<MusicSearchResult | null> {
try {
const params = new URLSearchParams({
@@ -100,7 +148,9 @@ async function searchJamendo(q: string, clientId: string, title?: string, artist
limit: '5',
format: 'json',
})
const res = await fetch(`https://api.jamendo.com/v3.0/tracks/?${params}`)
const res = await fetch(`https://api.jamendo.com/v3.0/tracks/?${params}`, {
signal: AbortSignal.timeout(6000),
})
if (!res.ok) return null
const data = (await res.json()) as { results?: { id: string; name: string; artist_name: string; audio: string }[] }
const results = data.results ?? []
@@ -127,24 +177,11 @@ async function searchJamendo(q: string, clientId: string, title?: string, artist
}
}
function scoreOdyseeItem(name: string, title: string, artist: string): number {
const n = name.toLowerCase().replace(/-/g, ' ')
const titleTerms = title.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
const artistTerms = artist.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
let score = 0
for (const term of titleTerms) {
if (n.includes(term)) score += 2
}
for (const term of artistTerms) {
if (n.includes(term)) score += 2
}
return score
}
async function searchOdysee(q: string, title?: string, artist?: string): Promise<MusicSearchResult | null> {
try {
const res = await fetch(
`https://lighthouse.odysee.tv/search?s=${encodeURIComponent(q)}&size=8`,
{ signal: AbortSignal.timeout(6000) },
)
if (!res.ok) return null
const items = (await res.json()) as { name: string; claimId: string }[]
@@ -171,6 +208,35 @@ async function searchOdysee(q: string, title?: string, artist?: string): Promise
}
}
// ─── Parallel search with preference ordering ─────────────────
async function searchAllProviders(
q: string,
jamendoClientId: string | undefined,
title?: string,
artist?: string,
): Promise<MusicSearchResult | null> {
// Fire all providers in parallel — streams are preferred over embeds
const providers = [
searchInternetArchive(q, title, artist),
jamendoClientId ? searchJamendo(q, jamendoClientId, title, artist) : Promise.resolve(null),
searchOdysee(q, title, artist),
]
const results = await Promise.allSettled(providers)
// Prefer streams (IA, Jamendo) over embeds (Odysee)
for (const r of results) {
if (r.status === 'fulfilled' && r.value?.type === 'stream') return r.value
}
for (const r of results) {
if (r.status === 'fulfilled' && r.value) return r.value
}
return null
}
// ─── Vite middleware ──────────────────────────────────────────
function createMusicSearchMiddleware(
jamendoClientId: string | undefined,
) {
@@ -186,10 +252,19 @@ function createMusicSearchMiddleware(
return
}
try {
const result =
(await searchInternetArchive(q, title ?? undefined, artist ?? undefined)) ??
(jamendoClientId ? await searchJamendo(q, jamendoClientId, title ?? undefined, artist ?? undefined) : null) ??
(await searchOdysee(q, title ?? undefined, artist ?? undefined))
const key = cacheKey(q, title ?? undefined, artist ?? undefined)
const cached = getCached(key)
if (cached !== undefined) {
res.setHeader('Content-Type', 'application/json')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Cache-Control', 'public, max-age=3600')
res.setHeader('X-Cache', 'HIT')
res.end(JSON.stringify(cached ?? { error: 'No results from any source' }))
return
}
const result = await searchAllProviders(q, jamendoClientId, title ?? undefined, artist ?? undefined)
setCache(key, result)
res.setHeader('Content-Type', 'application/json')
res.setHeader('Access-Control-Allow-Origin', '*')