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:
co-authored by
Claude Opus 4.6
parent
6c29e9e41d
commit
493657549e
@@ -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,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|2008–2013]] — 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|2022–present]] — 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|2022–present]] — 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|2015–2022]] — The Breaking Bad prequel that arguably surpasses it. Bob Odenkirk delivers a career-defining performance across 6 seasons.
|
||||
|
||||
[[tv_ext:True Detective|Nic Pizzolatto|2014–present]] — 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:
|
||||
|
||||

|
||||
|
||||
The granite towers catching first light create one of Earth's most dramatic mountain scenes.
|
||||
|
||||

|
||||
|
||||
The iconic arrowhead-shaped mountain with northern lights dancing above.
|
||||
|
||||

|
||||
|
||||
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|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<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 {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', '*')
|
||||
|
||||
Reference in New Issue
Block a user