Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 081dab5934
2056 changed files with 468143 additions and 0 deletions
@@ -0,0 +1,285 @@
/**
* Archy Integration Tests
*
* Tests archyBridge message handling, useArchy composable,
* and ArchyAppsGrid component behavior.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// ═══════════════════════════════════════════════════════════════════
// archyBridge — postMessage handling
// ═══════════════════════════════════════════════════════════════════
describe('archyBridge: postMessage protocol', () => {
let originalParent: Window
let messageHandler: ((event: MessageEvent) => void) | null = null
beforeEach(() => {
originalParent = window.parent
// Mock window.parent to simulate being in an iframe
Object.defineProperty(window, 'parent', {
value: {
postMessage: vi.fn(),
},
writable: true,
configurable: true,
})
// Capture addEventListener calls to grab the message handler
const originalAddEventListener = window.addEventListener.bind(window)
vi.spyOn(window, 'addEventListener').mockImplementation((type: string, handler: any) => {
if (type === 'message') {
messageHandler = handler
}
return originalAddEventListener(type, handler)
})
})
afterEach(() => {
Object.defineProperty(window, 'parent', {
value: originalParent,
writable: true,
configurable: true,
})
vi.restoreAllMocks()
messageHandler = null
})
it('isInArchy returns true when window.parent !== window', async () => {
// Dynamic import to get fresh module state
const { archyBridge } = await import('@/services/archyBridge')
expect(archyBridge.isInArchy()).toBe(true)
})
it('init sends ready message to parent', async () => {
const { archyBridge } = await import('@/services/archyBridge')
archyBridge.init()
expect(window.parent.postMessage).toHaveBeenCalledWith(
{ type: 'ready' },
window.location.origin,
)
archyBridge.destroy()
})
it('requestContext sends context:request message', async () => {
const { archyBridge } = await import('@/services/archyBridge')
archyBridge.init()
// Fire and forget — just verify the message shape
archyBridge.requestContext('apps').catch(() => {})
expect(window.parent.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'context:request',
category: 'apps',
}),
window.location.origin,
)
archyBridge.destroy()
})
it('requestAction sends action:request message', async () => {
const { archyBridge } = await import('@/services/archyBridge')
archyBridge.init()
// Fire and forget
archyBridge.requestAction('open-app', { appId: 'mempool' }).catch(() => {})
expect(window.parent.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'action:request',
action: 'open-app',
params: { appId: 'mempool' },
}),
window.location.origin,
)
archyBridge.destroy()
})
it('requestTheme sends theme:request message', async () => {
const { archyBridge } = await import('@/services/archyBridge')
archyBridge.init()
archyBridge.requestTheme()
expect(window.parent.postMessage).toHaveBeenCalledWith(
{ type: 'theme:request' },
window.location.origin,
)
archyBridge.destroy()
})
it('onPermissionsUpdate registers and fires callback', async () => {
const { archyBridge } = await import('@/services/archyBridge')
const callback = vi.fn()
const unsubscribe = archyBridge.onPermissionsUpdate(callback)
// Unsubscribe should be a function
expect(typeof unsubscribe).toBe('function')
unsubscribe()
})
it('onThemeUpdate registers and fires callback', async () => {
const { archyBridge } = await import('@/services/archyBridge')
const callback = vi.fn()
const unsubscribe = archyBridge.onThemeUpdate(callback)
expect(typeof unsubscribe).toBe('function')
unsubscribe()
})
it('getPermissions returns empty array initially', async () => {
const { archyBridge } = await import('@/services/archyBridge')
const perms = archyBridge.getPermissions()
expect(Array.isArray(perms)).toBe(true)
})
it('getTheme returns null initially', async () => {
const { archyBridge } = await import('@/services/archyBridge')
const theme = archyBridge.getTheme()
// May be null or have been set by a previous test
expect(theme === null || typeof theme === 'object').toBe(true)
})
})
// ═══════════════════════════════════════════════════════════════════
// useArchy — composable behavior
// ═══════════════════════════════════════════════════════════════════
describe('useArchy: composable', () => {
it('exports expected API shape', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
expect(archy).toHaveProperty('isEmbedded')
expect(archy).toHaveProperty('isInitialized')
expect(archy).toHaveProperty('permissions')
expect(archy).toHaveProperty('accentColor')
expect(archy).toHaveProperty('installedApps')
expect(archy).toHaveProperty('systemInfo')
expect(archy).toHaveProperty('networkInfo')
expect(archy).toHaveProperty('walletInfo')
expect(archy).toHaveProperty('fileList')
expect(archy).toHaveProperty('init')
expect(archy).toHaveProperty('destroy')
expect(archy).toHaveProperty('refreshContext')
expect(archy).toHaveProperty('requestAction')
expect(archy).toHaveProperty('buildArchyContext')
})
it('buildArchyContext returns empty string when not initialized', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
// Not initialized, should return empty
const ctx = archy.buildArchyContext()
expect(typeof ctx).toBe('string')
})
it('requestAction returns failure when not initialized', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
const result = await archy.requestAction('open-app', { appId: 'test' })
expect(result).toEqual({ success: false, error: 'Not initialized' })
})
it('isEmbedded and isInitialized are readonly refs', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
// Should be refs (have .value)
expect(typeof archy.isEmbedded.value).toBe('boolean')
expect(typeof archy.isInitialized.value).toBe('boolean')
})
})
// ═══════════════════════════════════════════════════════════════════
// useArchy: buildArchyContext output format
// ═══════════════════════════════════════════════════════════════════
describe('useArchy: buildArchyContext format', () => {
it('context string includes wallet info when available', async () => {
// We can't easily set internal state without init, so we test the format expectations
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
// Context should be a string
const ctx = archy.buildArchyContext()
expect(typeof ctx).toBe('string')
})
})
// ═══════════════════════════════════════════════════════════════════
// archy-apps.ts — data file
// ═══════════════════════════════════════════════════════════════════
describe('archy-apps: data integrity', () => {
it('exports ARCHY_APPS array with all major services', async () => {
const { ARCHY_APPS } = await import('@/data/archy-apps')
expect(Array.isArray(ARCHY_APPS)).toBe(true)
expect(ARCHY_APPS.length).toBeGreaterThanOrEqual(15)
// Verify all required services are present
const ids = ARCHY_APPS.map((a) => a.id)
expect(ids).toContain('bitcoin-core')
expect(ids).toContain('lnd')
expect(ids).toContain('btcpay-server')
expect(ids).toContain('mempool')
expect(ids).toContain('nextcloud')
expect(ids).toContain('immich')
expect(ids).toContain('nostr-rs-relay')
expect(ids).toContain('home-assistant')
expect(ids).toContain('grafana')
expect(ids).toContain('searxng')
expect(ids).toContain('ollama')
expect(ids).toContain('penpot')
expect(ids).toContain('onlyoffice')
expect(ids).toContain('fedimint')
expect(ids).toContain('meshtastic')
})
it('each app has required fields', async () => {
const { ARCHY_APPS } = await import('@/data/archy-apps')
for (const app of ARCHY_APPS) {
expect(app.id).toBeTruthy()
expect(app.name).toBeTruthy()
expect(app.description).toBeTruthy()
expect(app.icon).toBeTruthy()
expect(app.category).toBeTruthy()
expect(app.deepLink).toBeTruthy()
expect(app.deepLink.startsWith('/app/')).toBe(true)
}
})
it('getArchyApp looks up apps by ID', async () => {
const { getArchyApp } = await import('@/data/archy-apps')
const btc = getArchyApp('bitcoin-core')
expect(btc).toBeDefined()
expect(btc!.name).toBe('Bitcoin Core')
const missing = getArchyApp('nonexistent-app')
expect(missing).toBeUndefined()
})
it('app IDs are unique', async () => {
const { ARCHY_APPS } = await import('@/data/archy-apps')
const ids = ARCHY_APPS.map((a) => a.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('deep links follow /app/{id} pattern', async () => {
const { ARCHY_APPS } = await import('@/data/archy-apps')
for (const app of ARCHY_APPS) {
expect(app.deepLink).toBe(`/app/${app.id}`)
}
})
})
// ═══════════════════════════════════════════════════════════════════
// Base-aware API paths
// ═══════════════════════════════════════════════════════════════════
describe('Base-aware API paths', () => {
it('import.meta.env.BASE_URL is defined', () => {
// In Vite test environment, BASE_URL defaults to '/'
expect(typeof import.meta.env.BASE_URL).toBe('string')
expect(import.meta.env.BASE_URL).toBeTruthy()
})
})
@@ -0,0 +1,435 @@
import { describe, it, expect } from 'vitest'
import {
extractAllFilms,
extractAllSongs,
extractAllPodcasts,
extractAllBooks,
extractAllTVSeries,
extractAllPlaces,
extractAllImages,
extractMagazineSections,
extractMagazineHeroImage,
extractBoldDomainLinks,
extractMarkdownLinks,
mergeNewsResults,
stripContentTags,
stripFilmTags,
stripSongTags,
stripPodcastTags,
stripBookTags,
stripTVTags,
stripPlaceTags,
extractFilmIds,
extractSongIds,
extractPodcastIds,
} from '@/composables/contentExtraction'
// ─── Films ────────────────────────────────────────────────────────
describe('extractAllFilms', () => {
it('extracts film_ext tags with title, year, director', () => {
const text = 'Check out [[film_ext:Inception|2010|Christopher Nolan]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(1)
expect(films[0].title).toBe('Inception')
expect(films[0].year).toBe(2010)
expect(films[0].director).toBe('Christopher Nolan')
})
it('extracts film:id tags and looks up from library', () => {
const text = 'You should watch [[film:f1]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(1)
expect(films[0].id).toBe('f1')
})
it('returns empty array for text with no film tags', () => {
const text = 'Just some text about movies without any tags'
const films = extractAllFilms(text)
expect(films).toHaveLength(0)
})
it('handles multiple films in one message', () => {
const text = '[[film_ext:Inception|2010|Christopher Nolan]] and [[film_ext:Interstellar|2014|Christopher Nolan]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(2)
expect(films[0].title).toBe('Inception')
expect(films[1].title).toBe('Interstellar')
})
it('handles malformed tags gracefully', () => {
const text = '[[film_ext:]] [[film_ext:Incomplete]] [[film:]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(0)
})
it('deduplicates films by title and year', () => {
const text = '[[film_ext:Inception|2010|Nolan]] and again [[film_ext:Inception|2010|Nolan]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(1)
})
it('normalizes film IDs with or without f prefix', () => {
const ids1 = extractFilmIds('[[film:1]] [[film:f2]]')
expect(ids1).toEqual(['f1', 'f2'])
})
})
// ─── Songs ────────────────────────────────────────────────────────
describe('extractAllSongs', () => {
it('extracts song_ext tags with title, artist, year', () => {
const text = '[[song_ext:Bohemian Rhapsody|Queen|1975]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(1)
expect(songs[0].title).toBe('Bohemian Rhapsody')
expect(songs[0].artist).toBe('Queen')
expect(songs[0].year).toBe(1975)
})
it('extracts song_ext without year', () => {
const text = '[[song_ext:Paranoid Android|Radiohead]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(1)
expect(songs[0].title).toBe('Paranoid Android')
expect(songs[0].artist).toBe('Radiohead')
expect(songs[0].year).toBeUndefined()
})
it('extracts song:id tags from library', () => {
const text = 'Listen to [[song:s1]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(1)
expect(songs[0].id).toBe('s1')
})
it('deduplicates songs by title+artist', () => {
const text = '[[song_ext:Creep|Radiohead]] again [[song_ext:Creep|Radiohead]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(1)
})
it('returns empty for text with film tags (not music)', () => {
const text = '[[film_ext:Inception|2010|Nolan]] great movie'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(0)
})
it('filters out non-song content using looksLikeSong', () => {
const text = '[[song_ext:Latest News|Web Search]]'
const songs = extractAllSongs(text)
expect(songs).toHaveLength(0)
})
it('normalizes song IDs with or without s prefix', () => {
const ids = extractSongIds('[[song:1]] [[song:s2]]')
expect(ids).toEqual(['s1', 's2'])
})
})
// ─── Podcasts ─────────────────────────────────────────────────────
describe('extractAllPodcasts', () => {
it('extracts podcast_ext tags', () => {
const text = '[[podcast_ext:Bitcoin Audible|Guy Swann|2018]]'
const podcasts = extractAllPodcasts(text)
expect(podcasts).toHaveLength(1)
expect(podcasts[0].title).toBe('Bitcoin Audible')
expect(podcasts[0].host).toBe('Guy Swann')
expect(podcasts[0].year).toBe(2018)
})
it('extracts podcast:id from library', () => {
const text = '[[podcast:p1]]'
const podcasts = extractAllPodcasts(text)
expect(podcasts).toHaveLength(1)
expect(podcasts[0].id).toBe('p1')
})
it('returns empty for no podcast tags', () => {
const text = 'No podcasts here'
const podcasts = extractAllPodcasts(text)
expect(podcasts).toHaveLength(0)
})
it('filters out non-podcast titles', () => {
const text = '[[podcast_ext:Bitcoin Mailing List|GitHub]]'
const podcasts = extractAllPodcasts(text)
expect(podcasts).toHaveLength(0)
})
it('normalizes podcast IDs', () => {
const ids = extractPodcastIds('[[podcast:1]] [[podcast:p2]]')
expect(ids).toEqual(['p1', 'p2'])
})
})
// ─── Books ────────────────────────────────────────────────────────
describe('extractAllBooks', () => {
it('extracts book_ext tags with title, author, year', () => {
const text = '[[book_ext:The Bitcoin Standard|Saifedean Ammous|2018]]'
const books = extractAllBooks(text, 'best bitcoin books')
expect(books).toHaveLength(1)
expect(books[0].title).toBe('The Bitcoin Standard')
expect(books[0].author).toBe('Saifedean Ammous')
expect(books[0].year).toBe(2018)
})
it('handles optional year field', () => {
const text = '[[book_ext:Mastering Bitcoin|Andreas Antonopoulos]]'
const books = extractAllBooks(text, 'bitcoin books')
expect(books).toHaveLength(1)
expect(books[0].year).toBeUndefined()
})
it('returns empty when no book tags and not book query', () => {
const text = 'Just talking about tech'
const books = extractAllBooks(text, 'what is javascript')
expect(books).toHaveLength(0)
})
it('extracts multiple books', () => {
const text = '[[book_ext:Book One|Author A|2020]] and [[book_ext:Book Two|Author B|2021]]'
const books = extractAllBooks(text, 'books')
expect(books).toHaveLength(2)
})
})
// ─── TV Series ────────────────────────────────────────────────────
describe('extractAllTVSeries', () => {
it('extracts tv_ext tags', () => {
const text = '[[tv_ext:Breaking Bad|Vince Gilligan|2008]]'
const series = extractAllTVSeries(text, 'best tv shows')
expect(series).toHaveLength(1)
expect(series[0].title).toBe('Breaking Bad')
expect(series[0].creator).toBe('Vince Gilligan')
expect(series[0].year).toBe(2008)
})
it('parses creator field', () => {
const text = '[[tv_ext:The Wire|David Simon|2002]]'
const series = extractAllTVSeries(text, 'tv shows')
expect(series).toHaveLength(1)
expect(series[0].creator).toBe('David Simon')
})
it('returns empty when no TV tags and not TV query', () => {
const text = 'Some random text'
const series = extractAllTVSeries(text, 'cooking recipe')
expect(series).toHaveLength(0)
})
it('handles tv_ext without year', () => {
const text = '[[tv_ext:The Sopranos|David Chase]]'
const series = extractAllTVSeries(text, 'tv')
expect(series).toHaveLength(1)
expect(series[0].year).toBeUndefined()
})
})
// ─── Places ───────────────────────────────────────────────────────
describe('extractAllPlaces', () => {
it('extracts place_ext tags with all fields', () => {
const text = '[[place_ext:Joe\'s Pizza|Pizza|New York|4.5|2|123 Main St]]'
const places = extractAllPlaces(text, 'pizza near me')
expect(places).toHaveLength(1)
expect(places[0].name).toBe("Joe's Pizza")
expect(places[0].cuisine).toBe('Pizza')
expect(places[0].city).toBe('New York')
expect(places[0].rating).toBe(4.5)
expect(places[0].priceLevel).toBe(2)
expect(places[0].address).toBe('123 Main St')
})
it('handles missing optional fields (rating, price)', () => {
const text = '[[place_ext:The Diner|American]]'
const places = extractAllPlaces(text, 'restaurants')
expect(places).toHaveLength(1)
expect(places[0].name).toBe('The Diner')
expect(places[0].cuisine).toBe('American')
expect(places[0].rating).toBeUndefined()
expect(places[0].priceLevel).toBeUndefined()
})
it('returns empty for non-place queries without tags', () => {
const text = 'Nothing about restaurants here'
const places = extractAllPlaces(text, 'what is bitcoin')
expect(places).toHaveLength(0)
})
})
// ─── Images ───────────────────────────────────────────────────────
describe('extractAllImages', () => {
it('extracts markdown image syntax', () => {
const text = '![alt text](https://example.com/image.jpg)'
const images = extractAllImages(text, 'show me images')
expect(images).toHaveLength(1)
expect(images[0].url).toBe('https://example.com/image.jpg')
expect(images[0].alt).toBe('alt text')
})
it('extracts bare image URLs', () => {
const text = 'Check this: https://example.com/photo.png and https://example.com/pic.webp'
const images = extractAllImages(text, 'images')
expect(images).toHaveLength(2)
})
it('returns empty when not an image query and only one image', () => {
const text = 'https://example.com/photo.jpg'
const images = extractAllImages(text, 'what is bitcoin')
expect(images).toHaveLength(0)
})
})
// ─── Magazine Sections ────────────────────────────────────────────
describe('extractMagazineSections', () => {
it('extracts sections from markdown headings', () => {
const text = `## First Section
This is the content of the first section with enough text to pass the minimum length filter.
## Second Section
This is the content of the second section, also with enough detail to be meaningful.`
const sections = extractMagazineSections(text)
expect(sections.length).toBeGreaterThanOrEqual(2)
const titles = sections.map(s => s.title)
expect(titles).toContain('First Section')
expect(titles).toContain('Second Section')
})
it('captures content between headings', () => {
const text = `## Market Update
Bitcoin surged to new highs today as institutional demand increased significantly and retail sentiment improved.
## Analysis
Analysts believe the trend will continue through the end of the quarter as macro conditions stabilize.`
const sections = extractMagazineSections(text)
const marketSection = sections.find(s => s.title === 'Market Update')
expect(marketSection).toBeDefined()
expect(marketSection!.content).toContain('Bitcoin surged')
})
it('extracts hero images', () => {
const text = 'Some text with ![hero](https://example.com/hero.jpg) embedded'
const hero = extractMagazineHeroImage(text)
expect(hero).toBe('https://example.com/hero.jpg')
})
it('returns undefined for no images', () => {
const hero = extractMagazineHeroImage('No images here')
expect(hero).toBeUndefined()
})
it('extracts sections from numbered lists with bold titles', () => {
const text = `Here are the key developments:
1. **Strong price recovery** — Bitcoin climbed back above $60,000 as market confidence returned.
2. **Institutional adoption grows** — Major banks announced new crypto custody services for their clients.
3. **Regulatory clarity emerges** — New framework provides guidelines for digital asset companies.`
const sections = extractMagazineSections(text)
expect(sections.length).toBeGreaterThanOrEqual(3)
const titles = sections.map(s => s.title)
expect(titles).toContain('Strong price recovery')
})
it('handles empty or short text', () => {
const sections = extractMagazineSections('')
expect(sections).toHaveLength(0)
})
})
// ─── Tag Stripping ────────────────────────────────────────────────
describe('stripContentTags', () => {
it('removes all tag types from text', () => {
const text = 'Watch [[film:f1]] and listen to [[song:s1]] and read [[book_ext:Title|Author|2020]]'
const stripped = stripContentTags(text)
expect(stripped).not.toContain('[[film:')
expect(stripped).not.toContain('[[song:')
expect(stripped).not.toContain('[[book_ext:')
})
it('preserves non-tag content', () => {
const text = 'Watch this great movie [[film:f1]] and enjoy'
const stripped = stripContentTags(text)
expect(stripped).toContain('Watch this great movie')
expect(stripped).toContain('and enjoy')
})
it('handles adjacent tags', () => {
const text = '[[film:f1]][[song:s1]][[podcast:p1]]'
const stripped = stripContentTags(text)
expect(stripped).toBe('')
})
it('strips all specific tag types individually', () => {
expect(stripFilmTags('[[film:f1]] [[film_ext:Title|2020|Dir]]')).toBe('')
expect(stripSongTags('[[song:s1]] [[song_ext:Title|Artist]]')).toBe('')
expect(stripPodcastTags('[[podcast:p1]] [[podcast_ext:Title|Host]]')).toBe('')
expect(stripBookTags('[[book_ext:Title|Author|2020]]')).toBe('')
expect(stripTVTags('[[tv_ext:Title|Creator|2020]]')).toBe('')
expect(stripPlaceTags('[[place_ext:Name|Cuisine]]')).toBe('')
})
})
// ─── Links Extraction ─────────────────────────────────────────────
describe('extractBoldDomainLinks', () => {
it('extracts **domain.com** patterns with URLs', () => {
const text = '**CoinDesk** (coindesk.com) — the best source'
const links = extractBoldDomainLinks(text)
expect(links).toHaveLength(1)
expect(links[0].title).toBe('CoinDesk')
expect(links[0].url).toBe('https://coindesk.com')
})
it('deduplicates URLs', () => {
const text = '**CoinDesk** (coindesk.com) and **CoinDesk News** (coindesk.com)'
const links = extractBoldDomainLinks(text)
expect(links).toHaveLength(1)
})
})
describe('extractMarkdownLinks', () => {
it('extracts markdown links', () => {
const text = 'Check out [Bitcoin](https://bitcoin.org) for more'
const links = extractMarkdownLinks(text)
expect(links).toHaveLength(1)
expect(links[0].title).toBe('Bitcoin')
expect(links[0].url).toBe('https://bitcoin.org')
})
it('handles multiple links', () => {
const text = '[Link 1](https://example.com) and [Link 2](https://example.org/page)'
const links = extractMarkdownLinks(text)
expect(links).toHaveLength(2)
})
it('skips invalid URLs', () => {
const text = '[Bad Link](not-a-url)'
const links = extractMarkdownLinks(text)
expect(links).toHaveLength(0)
})
})
describe('mergeNewsResults', () => {
it('merges web results with text-extracted results', () => {
const web = [{ title: 'Web A', url: 'https://a.com', content: 'content a' }]
const fromText = [
{ title: 'Text B', url: 'https://b.com', content: undefined },
{ title: 'Text A Dup', url: 'https://a.com', content: undefined },
]
const merged = mergeNewsResults(web, fromText)
expect(merged).toHaveLength(2)
// Web result takes priority for same URL
const aResult = merged.find(r => r.url.includes('a.com'))
expect(aResult!.title).toBe('Web A')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
/**
* AIUI Guide — pre-loaded as a chat conversation so users can
* read it right in the chat window.
*/
export function guideToConversation(): {
id: string
title: string
messages: { id: string; role: string; content: string; timestamp: number }[]
createdAt: number
updatedAt: number
} {
const baseTime = 1772496000000
const guideContent = `# AIUI Guide — Your Node Assistant
AIUI is your AI assistant running directly on your Archipelago node. It can see your installed apps, read your files, check Bitcoin and Lightning status, and help you manage everything — all privately, with no data leaving your node.
---
## Node Awareness
AIUI automatically knows about your node setup. Just ask naturally:
- *"What apps do I have installed?"*
- *"Is my node connected to the network?"*
- *"What version of Archipelago am I running?"*
---
## File Browsing & Reading
AIUI can browse and read text files stored in your Nextcloud. Supported formats: \`.txt\`, \`.md\`, \`.json\`, \`.csv\`, \`.log\`, \`.yaml\`, \`.conf\`, \`.toml\`, \`.xml\`, \`.html\`, \`.css\`, \`.js\`, \`.ts\`, \`.py\`, \`.sh\`, and more.
- *"What files do I have?"*
- *"Read my config.yaml file"*
- *"Show me the contents of notes.md"*
- *"Summarize my todo.txt"*
> Files are read up to 100KB. Larger files are truncated. Binary files (images, videos) cannot be read as text.
---
## Bitcoin Node Status
If you have Bitcoin Core running, AIUI can check sync status, block height, and mempool info in real-time.
- *"How's my Bitcoin node doing?"*
- *"What block height am I on?"*
- *"Is my node fully synced?"*
- *"How many transactions are in the mempool?"*
---
## Lightning Network (LND)
AIUI can query your LND node for channels, peers, balances, and sync status. Private keys and macaroons are never exposed.
- *"What's my Lightning balance?"*
- *"How many channels do I have open?"*
- *"How many peers is my node connected to?"*
- *"Is my Lightning node synced?"*
---
## App Logs
When an app isn't working right, AIUI can pull recent log output to help diagnose issues.
- *"Why is Mempool not working?"*
- *"Show me the Bitcoin Core logs"*
- *"What errors is Nextcloud showing?"*
- *"Show me the last 100 lines of LND logs"*
---
## App Management
AIUI can help you navigate your node, open apps, and install new ones.
- *"Open Mempool"*
- *"Install BTCPay Server"*
- *"Take me to the Settings page"*
- *"What apps are available to install?"*
---
## Chat Features
- **Conversation History** — All chats saved locally. Use the history panel to switch between them.
- **Edit Messages** — Click any sent message to edit and re-send.
- **Branch Conversations** — Fork at any point to explore a different direction.
- **Web Search** — When enabled, AIUI searches the web for current info.
- **Image Support** — Attach images for visual questions.
---
## Privacy & Permissions
AIUI only accesses what you allow. Node data categories (apps, files, wallet, bitcoin, network, system) are permission-gated through the Archy permissions panel. All processing goes through your node's Claude proxy — your conversations and data never touch third-party servers beyond the AI API. Private keys, seeds, and macaroons are never exposed.
---
## Tips
- Be specific — *"Read my bitcoin.conf"* works better than *"show me config files"*
- AIUI remembers context within a conversation, so ask follow-ups
- If something seems wrong with an app, ask AIUI to check the logs first
- You can ask AIUI to explain what a config file does after reading it
- Use the history panel to return to previous conversations at any time
---
Try asking me something! For example: *"What apps do I have installed?"* or *"How's my Bitcoin node?"*`
return {
id: 'aiui-guide',
title: 'AIUI Guide',
messages: [
{
id: 'guide-q',
role: 'user',
content: 'How do I use AIUI?',
timestamp: baseTime,
},
{
id: 'guide-a',
role: 'assistant',
content: guideContent,
timestamp: baseTime + 2000,
},
],
createdAt: baseTime,
updatedAt: baseTime + 2000,
}
}
@@ -0,0 +1,269 @@
/**
* Node Demo Prompts — pre-populated conversations demonstrating
* AIUI's local node search and management capabilities.
*/
export interface NodeDemoPrompt {
id: string
userQuery: string
assistantResponse: string
}
export const nodeDemoPrompts: NodeDemoPrompt[] = [
{
id: 'node-apps',
userQuery: 'What apps do I have installed on my node?',
assistantResponse: `You have the following apps installed on your Archipelago node:
- **Bitcoin Core** (running) — Your Bitcoin full node, fully synced to the blockchain
- **LND** (running) — Lightning Network daemon for fast, cheap payments
- **Mempool** (running) — Visual Bitcoin blockchain explorer and mempool monitor
- **Nextcloud** (running) — Your private cloud storage for files, photos, and documents
- **BTCPay Server** (running) — Self-hosted payment processor for accepting Bitcoin
- **Tor** (running) — Anonymous network routing for privacy
All 6 apps are running normally. Would you like me to check the logs for any of them, or open one up?`,
},
{
id: 'node-bitcoin',
userQuery: "How's my Bitcoin node doing?",
assistantResponse: `Your Bitcoin Core node is looking healthy:
**Bitcoin Node Status**
- **Block Height:** 886,421
- **Sync Progress:** 100.00% (fully synced)
- **Chain:** mainnet
- **Mempool:** 42,318 transactions (68.2 MB)
Your node is fully caught up with the Bitcoin network. The mempool is moderately full — transaction fees are around 12-25 sat/vB for next-block confirmation. This is a typical level of activity.
Would you like me to check your Lightning node status as well?`,
},
{
id: 'node-lightning',
userQuery: "Yes, what's my Lightning balance?",
assistantResponse: `Here's your Lightning Network status:
**Lightning (LND)**
- **Node Alias:** ArchyNode-228
- **Channels:** 5 active channels
- **Peers:** 8 connected peers
- **Channel Balance:** 3,150,000 sats (in channels)
- **On-chain Balance:** 1,245,000 sats
- **Pending Open:** 0 sats
- **Sync:** Fully synced to chain
Your total Lightning capacity is about 3.15M sats across 5 channels, plus 1.24M sats on-chain. Everything looks healthy — all channels are active and your node is fully synced.
Your total Bitcoin holdings visible to the node: **4,395,000 sats** (~$4,395 at current rates).`,
},
{
id: 'node-files',
userQuery: 'What files do I have stored on my node?',
assistantResponse: `You have **47 files** in your Nextcloud storage. Here are the most recent:
- bitcoin.conf
- lnd.conf
- notes.md
- budget-2026.csv
- family-photos/ (folder)
- Documents/ (folder)
- backup-keys.txt
- todo.txt
- project-ideas.md
- recipes/ (folder)
Would you like me to read any of these files? I can open text files like .txt, .md, .json, .csv, .conf, .yaml, and more.`,
},
{
id: 'node-read-file',
userQuery: 'Read my todo.txt',
assistantResponse: `Here's the contents of **todo.txt** (342 bytes):
\`\`\`
Shopping List
- Groceries for the week
- New HDMI cable for the node monitor
- Backup hard drive (4TB minimum)
Node Tasks
- Open more Lightning channels
- Set up Ride The Lightning (RTL)
- Configure automatic channel backups
- Test BTCPay Server checkout flow
Personal
- Call dentist for appointment
- Renew domain registration
- Plan weekend hike
\`\`\`
Looks like you've got some node tasks on your list! I can help you with a few of those — I can open the app marketplace to install Ride The Lightning, or check your current channel backup configuration. Want me to help with any of these?`,
},
{
id: 'node-read-config',
userQuery: 'Show me my bitcoin.conf',
assistantResponse: `Here's your **bitcoin.conf** (512 bytes):
\`\`\`conf
# Bitcoin Core Configuration
server=1
daemon=1
txindex=1
# RPC Settings
rpcuser=archipelago
rpcpassword=EXAMPLE-ONLY-not-a-real-password
rpcallowip=127.0.0.1
rpcbind=127.0.0.1
rpcport=8332
# Network
listen=1
maxconnections=40
maxuploadtarget=5000
# Mempool
maxmempool=300
mempoolexpiry=72
# Performance
dbcache=450
par=2
# ZMQ (for LND)
zmqpubrawblock=tcp://127.0.0.1:28332
zmqpubrawtx=tcp://127.0.0.1:28333
\`\`\`
Your config looks solid. A few observations:
- **txindex=1** is enabled, which is needed for Electrum Server and block explorers
- **dbcache=450** MB — reasonable for your hardware
- **ZMQ** is configured for LND to receive real-time block/tx notifications
- **maxuploadtarget=5000** MB/day limits upload bandwidth
Everything is properly set up for running LND and Mempool alongside Bitcoin Core.`,
},
{
id: 'node-app-logs',
userQuery: "Mempool seems slow, can you check what's going on?",
assistantResponse: `Let me check the Mempool logs for you.
Here are the last 50 lines of **Mempool** logs:
\`\`\`
[2026-03-05 14:23:01] Updating mempool stats...
[2026-03-05 14:23:02] Fetched 42318 transactions from Bitcoin Core
[2026-03-05 14:23:03] Block 886421 indexed successfully
[2026-03-05 14:23:05] WebSocket: 12 clients connected
[2026-03-05 14:23:10] Mining pool stats updated
[2026-03-05 14:23:15] Mempool size: 68.2 MB (42318 txs)
[2026-03-05 14:23:20] Fee estimation updated: 12-25 sat/vB
[2026-03-05 14:23:25] WARN: Electrs sync delay detected (2.3s)
[2026-03-05 14:23:30] Updating difficulty adjustment...
[2026-03-05 14:23:35] Hashrate calculation complete
\`\`\`
I see one warning: **Electrs sync delay of 2.3 seconds**. This is the Electrum Server indexer that Mempool uses for address lookups. A small delay like this is usually temporary and happens when:
1. A new block was just found (extra indexing work)
2. The mempool is particularly full (more data to process)
The rest of the logs look normal. Mempool is serving 12 active WebSocket connections. If the slowness persists, you might want to restart the Mempool container. Want me to do that?`,
},
{
id: 'node-open-app',
userQuery: 'Open Mempool so I can check it myself',
assistantResponse: `Opening **Mempool** for you now. The app should appear in a new view — you'll see the real-time mempool visualization, fee estimates, and recent blocks.
If it's still feeling slow, try refreshing the page. The Electrs sync delay I noticed earlier should resolve itself within a few minutes.`,
},
{
id: 'node-install',
userQuery: 'What other apps can I install?',
assistantResponse: `Here are some apps available in the Archipelago marketplace that you haven't installed yet:
**Recommended for your setup:**
- **Ride The Lightning (RTL)** — Visual Lightning node manager with channel management, payments, and routing fee settings. Great companion to LND.
- **ThunderHub** — Another Lightning dashboard with a modern UI. More advanced routing and rebalancing tools.
- **BOS (Balance of Satoshis)** — CLI tool for advanced Lightning channel management and rebalancing.
**Privacy & Communication:**
- **Nostr Relay** — Run your own Nostr relay for censorship-resistant social media
- **SimpleX Chat Server** — Private messaging server
**Other:**
- **Home Assistant** — Smart home automation
- **Gitea** — Self-hosted Git repository (like a private GitHub)
- **Vaultwarden** — Self-hosted password manager (Bitwarden compatible)
Since you mentioned wanting to set up RTL in your todo list, would you like me to install **Ride The Lightning** for you?`,
},
{
id: 'node-summary',
userQuery: 'Give me a full status summary of my node',
assistantResponse: `Here's your complete **Archipelago Node Status**:
---
**System**
- Archipelago v0.1.0
- Network: Connected
- Uptime: 14 days, 6 hours
**Bitcoin Core** — Running
- Block: 886,421 (100% synced)
- Mempool: 42,318 txs (68.2 MB)
- Connections: 38 peers
**Lightning (LND)** — Running
- Alias: ArchyNode-228
- Channels: 5 active, 0 pending
- Peers: 8 connected
- Channel balance: 3,150,000 sats
- On-chain balance: 1,245,000 sats
- Total: 4,395,000 sats
**Apps** — 6/6 running
- Bitcoin Core, LND, Mempool, Nextcloud, BTCPay Server, Tor
**Storage**
- Nextcloud: 47 files
- Blockchain: ~600 GB
---
Everything is running smoothly. No errors or warnings in the last 24 hours. Your node is fully synced, all channels are active, and all apps are healthy.`,
},
]
/**
* Build a single conversation with all node demo prompts as sequential messages.
*/
export function nodeDemoToConversation(): {
id: string
title: string
messages: { id: string; role: string; content: string; timestamp: number }[]
createdAt: number
updatedAt: number
} {
const baseTime = 1772492400000 // Slightly after seed prompts
const messages: { id: string; role: string; content: string; timestamp: number }[] = []
for (let i = 0; i < nodeDemoPrompts.length; i++) {
const prompt = nodeDemoPrompts[i]
const ts = baseTime + i * 120000 // 2 min between each exchange
messages.push(
{ id: `${prompt.id}-q`, role: 'user', content: prompt.userQuery, timestamp: ts },
{ id: `${prompt.id}-a`, role: 'assistant', content: prompt.assistantResponse, timestamp: ts + 5000 },
)
}
return {
id: 'node-demo',
title: 'Exploring My Node',
messages,
createdAt: baseTime,
updatedAt: baseTime + nodeDemoPrompts.length * 120000,
}
}
@@ -0,0 +1,473 @@
/**
* 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.`,
// Six, not ten. The assistantResponse above lists exactly six
// `song_ext` entries and ends coherently on Treefingers — it was never
// truncated. The extractor was returning the right answer and this
// number was the wrong one, which is why "extracts 10 songs" had been
// failing. Counted from the fixture, not from intent.
expected: { songs: 6 },
},
// ─── 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.
*/
/** Build a single conversation with all seed prompts as sequential messages. */
export function seedPromptsToConversation(): {
id: string
title: string
messages: { id: string; role: string; content: string; timestamp: number }[]
createdAt: number
updatedAt: number
} {
const baseTime = 1772488800000
const messages: { id: string; role: string; content: string; timestamp: number }[] = []
for (let i = 0; i < seedPrompts.length; i++) {
const seed = seedPrompts[i]
const ts = baseTime + i * 60000
messages.push(
{ id: `${seed.id}-q`, role: 'user', content: seed.userQuery, timestamp: ts },
{ id: `${seed.id}-a`, role: 'assistant', content: seed.assistantResponse, timestamp: ts + 3000 },
)
}
return {
id: 'seed-all',
title: 'Content Showcase',
messages,
createdAt: baseTime,
updatedAt: baseTime + seedPrompts.length * 60000,
}
}
@@ -0,0 +1,188 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock proxy request/response logic without actually spawning processes
describe('Proxy Integration', () => {
describe('SSE streaming format', () => {
it('should produce valid SSE content_block_delta events', () => {
const text = 'Hello, world!'
const sseData = {
type: 'content_block_delta',
delta: { type: 'text_delta', text },
}
const sseString = `data: ${JSON.stringify(sseData)}\n\n`
expect(sseString).toMatch(/^data: /)
expect(sseString).toMatch(/\n\n$/)
const parsed = JSON.parse(sseString.replace('data: ', '').trim())
expect(parsed.type).toBe('content_block_delta')
expect(parsed.delta.type).toBe('text_delta')
expect(parsed.delta.text).toBe(text)
})
it('should produce valid DONE event', () => {
const done = 'data: [DONE]\n\n'
expect(done).toBe('data: [DONE]\n\n')
})
it('should produce valid error events', () => {
const errData = {
type: 'error',
error: { message: 'Anthropic API 401: Unauthorized' },
}
const sseString = `data: ${JSON.stringify(errData)}\n\n`
const parsed = JSON.parse(sseString.replace('data: ', '').trim())
expect(parsed.type).toBe('error')
expect(parsed.error.message).toContain('401')
})
})
describe('Model mapping', () => {
function mapModelToApi(model: string): string {
if (model?.includes('opus')) return 'claude-opus-4-20250514'
if (model?.includes('haiku')) return 'claude-haiku-4-5-20251001'
return 'claude-sonnet-4-20250514'
}
it('should map sonnet model correctly', () => {
expect(mapModelToApi('sonnet')).toBe('claude-sonnet-4-20250514')
expect(mapModelToApi('claude-sonnet')).toBe('claude-sonnet-4-20250514')
})
it('should map opus model correctly', () => {
expect(mapModelToApi('opus')).toBe('claude-opus-4-20250514')
expect(mapModelToApi('claude-opus')).toBe('claude-opus-4-20250514')
})
it('should map haiku model correctly', () => {
expect(mapModelToApi('haiku')).toBe('claude-haiku-4-5-20251001')
})
it('should default to sonnet for unknown models', () => {
expect(mapModelToApi('unknown')).toBe('claude-sonnet-4-20250514')
})
})
describe('Request validation', () => {
it('should reject non-POST requests', () => {
const method = 'GET' as string
const isValid = method === 'POST'
expect(isValid).toBe(false)
})
it('should reject unknown paths', () => {
const validPaths = ['/v1/messages', '/v1/openrouter']
expect(validPaths.includes('/v1/unknown')).toBe(false)
expect(validPaths.includes('/v1/messages')).toBe(true)
expect(validPaths.includes('/v1/openrouter')).toBe(true)
})
it('should parse request body correctly', () => {
const body = JSON.stringify({
model: 'sonnet',
messages: [{ role: 'user', content: 'Hello' }],
system: 'You are helpful.',
webSearch: true,
})
const parsed = JSON.parse(body)
expect(parsed.model).toBe('sonnet')
expect(parsed.messages).toHaveLength(1)
expect(parsed.system).toBe('You are helpful.')
expect(parsed.webSearch).toBe(true)
})
it('should handle malformed JSON', () => {
const badBody = 'not json'
expect(() => JSON.parse(badBody)).toThrow()
})
})
describe('Tool use round-trips', () => {
it('should format search_web tool correctly', () => {
const tool = {
name: 'search_web',
description: 'Search the web for current information.',
input_schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
},
required: ['query'],
},
}
expect(tool.name).toBe('search_web')
expect(tool.input_schema.properties.query.type).toBe('string')
})
it('should construct tool_result messages correctly', () => {
const toolResult = {
type: 'tool_result',
tool_use_id: 'toolu_123',
content: '1. [Bitcoin price](https://example.com) — Current price is...',
}
expect(toolResult.type).toBe('tool_result')
expect(toolResult.tool_use_id).toBe('toolu_123')
expect(toolResult.content).toContain('Bitcoin')
})
it('should limit tool rounds to 5', () => {
const maxToolRounds = 5
let rounds = 0
while (rounds < maxToolRounds) {
rounds++
}
expect(rounds).toBe(5)
})
})
describe('Error handling', () => {
it('should handle 401 unauthorized', () => {
const status = 401
const errMsg = `Anthropic API ${status}: Unauthorized`
expect(errMsg).toContain('401')
})
it('should handle 429 rate limit', () => {
const status = 429
const errMsg = `Anthropic API ${status}: Rate limited`
expect(errMsg).toContain('429')
})
it('should handle 500 server error', () => {
const status = 500
const errMsg = `Anthropic API ${status}: Internal Server Error`
expect(errMsg).toContain('500')
})
})
describe('OAuth token detection', () => {
const isOAuthToken = (s: string) => /^sk-ant-oat/.test(s)
it('should detect OAuth tokens', () => {
expect(isOAuthToken('sk-ant-oat-abc123')).toBe(true)
})
it('should not flag API keys as OAuth', () => {
expect(isOAuthToken('sk-ant-api03-abc123')).toBe(false)
})
})
describe('Client disconnect handling', () => {
it('should track client disconnection', () => {
const state = { clientDisconnected: false }
// Simulate disconnect
state.clientDisconnected = true
expect(state.clientDisconnected).toBe(true)
})
it('should not write after disconnect', () => {
const clientDisconnected = true
const writes: string[] = []
const write = (data: string) => {
if (!clientDisconnected) writes.push(data)
}
write('should not appear')
expect(writes).toHaveLength(0)
})
})
})
@@ -0,0 +1,59 @@
/**
* Seed Conversation Regression Tests
*
* Ensures each seed conversation produces at least the expected content types
* when run through the content extraction pipeline. This is a quick smoke test
* that verifies the extraction pipeline hasn't regressed.
*/
import { describe, it, expect } from 'vitest'
import { seedPrompts } from './fixtures/seedPrompts'
import {
extractAllFilms,
extractAllSongs,
extractAllPodcasts,
extractAllBooks,
extractAllTVSeries,
extractAllPlaces,
extractAllImages,
extractCodeBlocks,
extractRecipes,
extractEvents,
stripContentTags,
} from '@/composables/contentExtraction'
describe('Seed conversation regression', () => {
for (const seed of seedPrompts) {
it(`[${seed.id}] produces expected content types for "${seed.userQuery.slice(0, 50)}"`, () => {
const text = seed.assistantResponse
const query = seed.userQuery
// Run all extractors
const films = extractAllFilms(text)
const songs = extractAllSongs(text, query)
const podcasts = extractAllPodcasts(text)
const books = extractAllBooks(text, query)
const tvSeries = extractAllTVSeries(text, query)
const places = extractAllPlaces(text, query)
const images = extractAllImages(text, query)
const codeBlocks = extractCodeBlocks(text)
const recipes = extractRecipes(text)
const events = extractEvents(text)
// Validate expected counts match
if (seed.expected.films !== undefined) expect(films.length).toBe(seed.expected.films)
if (seed.expected.songs !== undefined) expect(songs.length).toBe(seed.expected.songs)
if (seed.expected.podcasts !== undefined) expect(podcasts.length).toBe(seed.expected.podcasts)
if (seed.expected.books !== undefined) expect(books.length).toBe(seed.expected.books)
if (seed.expected.tvSeries !== undefined) expect(tvSeries.length).toBe(seed.expected.tvSeries)
if (seed.expected.places !== undefined) expect(places.length).toBe(seed.expected.places)
if (seed.expected.images !== undefined) expect(images.length).toBe(seed.expected.images)
if (seed.expected.codeBlocks !== undefined) expect(codeBlocks.length).toBe(seed.expected.codeBlocks)
if (seed.expected.recipes !== undefined) expect(recipes.length).toBe(seed.expected.recipes)
if (seed.expected.events !== undefined) expect(events.length).toBe(seed.expected.events)
// Verify stripping leaves no tags
const stripped = stripContentTags(text)
expect(stripped).not.toMatch(/\[\[[^\]]+\]\]/)
})
}
})
@@ -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()
}
})
})
@@ -0,0 +1,384 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// Mock web search before importing useAI
vi.mock('@/composables/useWebSearch', () => ({
searchWeb: vi.fn().mockResolvedValue([]),
}))
// Mock mock data to avoid importing large fixture files
vi.mock('@/mocks/films', () => ({
mockFilms: [
{ id: 'f1', title: 'Test Film', year: 2020, director: 'Test Dir', genres: ['Drama'], rating: 8, sources: [{ type: 'stream' }] },
],
}))
vi.mock('@/mocks/songs', () => ({
mockSongs: [
{ id: 's1', title: 'Test Song', artist: 'Test Artist', album: 'Test Album', year: 2020, genres: ['Rock'], sources: [{ type: 'jamendo' }] },
],
}))
vi.mock('@/mocks/podcasts', () => ({
mockPodcasts: [
{ id: 'p1', title: 'Test Podcast', host: 'Test Host', year: 2020, genres: ['Tech'], sources: [{ type: 'rss' }] },
],
}))
// Mock idb-storage to avoid IndexedDB access in tests
vi.mock('@/utils/idb-storage', () => ({
saveConversation: vi.fn().mockResolvedValue(undefined),
loadAllConversations: vi.fn().mockResolvedValue(new Map()),
deleteConversation: vi.fn().mockResolvedValue(undefined),
isIDBAvailable: vi.fn().mockReturnValue(false),
}))
import { useAI } from '@/composables/useAI'
import { useChatStore } from '@/stores/chat'
import { searchWeb } from '@/composables/useWebSearch'
const originalFetch = globalThis.fetch
// Helper to create a mock SSE readable stream
function createSSEStream(events: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
let index = 0
return new ReadableStream({
pull(controller) {
if (index < events.length) {
controller.enqueue(encoder.encode(events[index]))
index++
} else {
controller.close()
}
},
})
}
function mockClaudeResponse(events: string[]) {
return {
ok: true,
body: createSSEStream(events),
text: () => Promise.resolve(''),
}
}
describe('useAI', () => {
beforeEach(() => {
setActivePinia(createPinia())
// Reset provider state (module-level ref) back to claude
const { setProvider } = useAI()
setProvider('claude')
// Re-mock searchWeb
vi.mocked(searchWeb).mockResolvedValue([])
// Default fetch mock (catches loadServerChats and any stray calls)
globalThis.fetch = originalFetch
})
describe('provider selection', () => {
it('defaults to claude provider', () => {
const { activeProvider } = useAI()
expect(activeProvider.value).toBe('claude')
})
it('switches provider via setProvider', () => {
const { setProvider, activeProvider, activeModel } = useAI()
setProvider('openrouter')
expect(activeProvider.value).toBe('openrouter')
expect(activeModel.value).toBe('meta-llama/llama-4-maverick')
})
it('switches to mock provider', () => {
const { setProvider, activeProvider, activeModel } = useAI()
setProvider('mock')
expect(activeProvider.value).toBe('mock')
expect(activeModel.value).toBe('echo')
})
it('lists available providers with models', () => {
const { availableProviders } = useAI()
expect(availableProviders.value.length).toBe(3)
const ids = availableProviders.value.map(p => p.id)
expect(ids).toContain('claude')
expect(ids).toContain('openrouter')
expect(ids).toContain('mock')
})
it('sets model directly via setModel', () => {
const { setModel, activeModel } = useAI()
setModel('claude-sonnet-4')
expect(activeModel.value).toBe('claude-sonnet-4')
})
})
describe('context injection', () => {
it('includes film library in system prompt', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n'])
)
globalThis.fetch = fetchSpy
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage } = useAI()
await sendMessage('hello')
const claudeCall = fetchSpy.mock.calls.find(
(c: unknown[]) => (c[0] as string)?.toString().includes('/claude/')
)
expect(claudeCall).toBeDefined()
const body = JSON.parse(claudeCall![1].body as string)
expect(body.system).toContain('Test Film')
expect(body.system).toContain("user's film library")
})
it('includes song library in system prompt', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n'])
)
globalThis.fetch = fetchSpy
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage } = useAI()
await sendMessage('hello')
const claudeCall = fetchSpy.mock.calls.find(
(c: unknown[]) => (c[0] as string)?.toString().includes('/claude/')
)
const body = JSON.parse(claudeCall![1].body as string)
expect(body.system).toContain('Test Song')
expect(body.system).toContain("user's song library")
})
it('includes content tag format instructions', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n'])
)
globalThis.fetch = fetchSpy
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage } = useAI()
await sendMessage('hello')
const claudeCall = fetchSpy.mock.calls.find(
(c: unknown[]) => (c[0] as string)?.toString().includes('/claude/')
)
const body = JSON.parse(claudeCall![1].body as string)
expect(body.system).toContain('[[film_ext:')
expect(body.system).toContain('[[song_ext:')
expect(body.system).toContain('[[book_ext:')
})
})
describe('sendMessage', () => {
it('adds user message to store', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"ok"}}\n\n'])
)
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage } = useAI()
await sendMessage('test message')
const userMsg = chatStore.messages.find(m => m.role === 'user')
expect(userMsg).toBeDefined()
expect(userMsg!.content).toBe('test message')
})
it('creates assistant message placeholder', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"hi"}}\n\n'])
)
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage } = useAI()
await sendMessage('test')
const assistantMsg = chatStore.messages.find(m => m.role === 'assistant')
expect(assistantMsg).toBeDefined()
expect(assistantMsg!.content).toContain('hi')
})
it('sets isStreaming to true during stream and false after', async () => {
const streamingStates: boolean[] = []
globalThis.fetch = vi.fn().mockImplementation(() => {
streamingStates.push(useChatStore().isStreaming)
return Promise.resolve(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"hello"}}\n\n'])
)
})
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage } = useAI()
expect(chatStore.isStreaming).toBe(false)
await sendMessage('hi')
expect(chatStore.isStreaming).toBe(false)
// During the streaming fetch call, isStreaming should have been true
// (earlier non-streaming fetches like refreshWavlakeCatalog may also be captured)
expect(streamingStates.some(s => s === true)).toBe(true)
})
it('handles stream errors gracefully', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.resolve('Internal Server Error'),
})
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage, setProvider } = useAI()
setProvider('claude') // Ensure claude provider
await sendMessage('test')
const assistantMsg = chatStore.messages.find(m => m.role === 'assistant')
expect(assistantMsg).toBeDefined()
expect(assistantMsg!.content).toContain('⚠')
expect(chatStore.isStreaming).toBe(false)
})
it('handles connection errors gracefully', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network failure'))
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage, setProvider } = useAI()
setProvider('claude') // Ensure claude provider
await sendMessage('test')
const assistantMsg = chatStore.messages.find(m => m.role === 'assistant')
expect(assistantMsg).toBeDefined()
expect(assistantMsg!.content).toContain('Connection error')
expect(chatStore.isStreaming).toBe(false)
})
it('uses mock provider when set to mock', async () => {
const { sendMessage, setProvider } = useAI()
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
setProvider('mock')
await sendMessage('echo this')
const assistantMsg = chatStore.messages.find(m => m.role === 'assistant')
expect(assistantMsg).toBeDefined()
expect(assistantMsg!.content).toContain('echo this')
expect(assistantMsg!.content).toContain('echo mode')
})
})
describe('stopGeneration', () => {
it('aborts active stream and sets isStreaming to false', async () => {
// Create a fetch that returns a stream which rejects on abort
globalThis.fetch = vi.fn().mockImplementation((_url: string, init?: RequestInit) => {
const signal = init?.signal
return Promise.resolve({
ok: true,
body: new ReadableStream<Uint8Array>({
start(controller) {
// Send first chunk so readSSE enters its loop
const encoder = new TextEncoder()
controller.enqueue(encoder.encode('data: {"type":"content_block_delta","delta":{"text":"h"}}\n\n'))
// When aborted, close the stream
if (signal) {
signal.addEventListener('abort', () => {
try { controller.close() } catch { /* already closed */ }
})
}
},
}),
text: () => Promise.resolve(''),
})
})
const chatStore = useChatStore()
chatStore.webSearchEnabled = false
const { sendMessage, stopGeneration, setProvider } = useAI()
setProvider('claude')
const sendPromise = sendMessage('test')
// Wait for fetch to start and first chunk to process
await new Promise(r => setTimeout(r, 50))
expect(chatStore.isStreaming).toBe(true)
stopGeneration()
expect(chatStore.isStreaming).toBe(false)
await sendPromise
})
})
describe('web search integration', () => {
it('injects web results into system prompt when enabled', async () => {
const mockResults = [
{ title: 'Result 1', url: 'https://example.com', content: 'Some content' },
]
vi.mocked(searchWeb).mockResolvedValue(mockResults)
const fetchSpy = vi.fn().mockResolvedValue(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"answer"}}\n\n'])
)
globalThis.fetch = fetchSpy
const chatStore = useChatStore()
chatStore.webSearchEnabled = true
const { sendMessage } = useAI()
await sendMessage('latest bitcoin news')
// Find the Claude API call (not any dev-chats call)
const claudeCall = fetchSpy.mock.calls.find(
(c: unknown[]) => (c[0] as string)?.toString().includes('/claude/')
)
expect(claudeCall).toBeDefined()
const body = JSON.parse(claudeCall![1].body as string)
expect(body.system).toContain('Web search results')
expect(body.system).toContain('Result 1')
// FALSE on purpose: `proxyWebSearch = webSearchEnabled && !clientSearchSucceeded`.
// The client already searched and injected the results above, so asking
// the proxy to search again would be a second, redundant search per turn.
// This assertion read `true` and had been failing since that change —
// the test was stale, the behaviour is intentional.
expect(body.webSearch).toBe(false)
})
it('asks the proxy to search when the client-side search finds nothing', async () => {
// The other half of the same contract: no client results means nothing
// was injected, so the proxy must still do the search.
vi.mocked(searchWeb).mockResolvedValue([])
const fetchSpy = vi.fn().mockResolvedValue(
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"answer"}}\n\n'])
)
globalThis.fetch = fetchSpy
const chatStore = useChatStore()
chatStore.webSearchEnabled = true
const { sendMessage } = useAI()
await sendMessage('latest bitcoin news')
const claudeCall = fetchSpy.mock.calls.find(
(c: unknown[]) => (c[0] as string)?.toString().includes('/claude/')
)
expect(claudeCall).toBeDefined()
const body = JSON.parse(claudeCall![1].body as string)
expect(body.system).not.toContain('Web search results')
expect(body.webSearch).toBe(true)
})
})
})