Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,166 @@
import { describe, it, expect } from 'vitest'
import {
extractAllFilms,
extractAllSongs,
extractAllBooks,
extractAllTVSeries,
extractAllPlaces,
extractAllImages,
extractMagazineSections,
stripContentTags,
} from '../contentExtraction'
// ─── Edge cases ──────────────────────────────────────────────────
describe('contentExtraction edge cases', () => {
it('handles empty string', () => {
expect(extractAllFilms('')).toEqual([])
expect(extractAllSongs('', '')).toEqual([])
expect(extractAllBooks('', '')).toEqual([])
expect(extractAllTVSeries('', '')).toEqual([])
expect(extractAllPlaces('', '')).toEqual([])
expect(extractAllImages('', '')).toEqual([])
expect(extractMagazineSections('')).toEqual([])
})
it('handles text with no tags', () => {
const text = 'Just a regular response about movies and music.'
expect(extractAllFilms(text)).toEqual([])
expect(extractAllSongs(text, '')).toEqual([])
})
it('handles interleaved tags of different types', () => {
const text = `
Here are some recommendations:
[[film_ext:Inception|2010|Christopher Nolan]]
Then a great song:
[[song_ext:Bohemian Rhapsody|Queen|1975]]
And a book:
[[book_ext:Dune|Frank Herbert|1965]]
`
const films = extractAllFilms(text)
const songs = extractAllSongs(text, '')
const books = extractAllBooks(text, '')
expect(films).toHaveLength(1)
expect(films[0].title).toBe('Inception')
expect(songs).toHaveLength(1)
expect(songs[0].title).toBe('Bohemian Rhapsody')
expect(books).toHaveLength(1)
expect(books[0].title).toBe('Dune')
})
it('handles malformed tags (missing fields)', () => {
// film_ext requires title|year|director — missing any means no match
expect(extractAllFilms('[[film_ext:Inception]]')).toHaveLength(0)
expect(extractAllFilms('[[film_ext:Inception|2010]]')).toHaveLength(0)
// All 3 fields present — matches
const films = extractAllFilms('[[film_ext:Inception|2010|Christopher Nolan]]')
expect(films).toHaveLength(1)
expect(films[0].title).toBe('Inception')
})
it('handles unicode content in tags', () => {
const text = '[[film_ext:千と千尋の神隠し|2001|宮崎駿]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(1)
expect(films[0].title).toBe('千と千尋の神隠し')
})
it('handles tags with extra whitespace in pipe-separated values', () => {
// Regex captures include leading/trailing spaces in groups
// but the tag format requires no spaces around brackets
const text = '[[film_ext:Inception|2010|Christopher Nolan]]'
const films = extractAllFilms(text)
expect(films).toHaveLength(1)
expect(films[0].title).toBe('Inception')
expect(films[0].director).toBe('Christopher Nolan')
})
it('handles duplicate tags (same title)', () => {
const text = `
[[film_ext:Inception|2010|Christopher Nolan]]
[[film_ext:Inception|2010|Christopher Nolan]]
`
const films = extractAllFilms(text)
// Should deduplicate
expect(films).toHaveLength(1)
})
it('handles place_ext with all fields', () => {
const text = '[[place_ext:Sushi Nakazawa|40.7258|-74.0030|Japanese|$$$$|New York]]'
const places = extractAllPlaces(text, '')
expect(places).toHaveLength(1)
expect(places[0].name).toBe('Sushi Nakazawa')
})
it('handles tv_ext tags', () => {
const text = '[[tv_ext:Breaking Bad|2008|Vince Gilligan]]'
const series = extractAllTVSeries(text, '')
expect(series).toHaveLength(1)
expect(series[0].title).toBe('Breaking Bad')
})
it('stripContentTags removes all tag types', () => {
const text = 'Watch [[film_ext:Inception|2010|Nolan]] and listen to [[song_ext:Song|Artist|2020]]'
const stripped = stripContentTags(text)
expect(stripped).not.toContain('[[')
expect(stripped).not.toContain(']]')
expect(stripped).toContain('Watch')
expect(stripped).toContain('and listen to')
})
it('handles magazine sections with bold markers', () => {
const text = `
- **Bitcoin rallies**: Price surges 5% as ETF inflows hit record.
- **Lightning Network growth**: Capacity doubles in Q1 2026.
- **Mining difficulty**: New all-time high reached.
`
const sections = extractMagazineSections(text)
expect(sections.length).toBeGreaterThanOrEqual(1)
// First section is a summary grouping the items
const titles = sections.map(s => s.title)
expect(titles.length).toBeGreaterThan(0)
})
})
describe('description pairing (operator-reported: cards carried the WRONG description)', () => {
// Verbatim shape of the Bitcoin-films answer that produced mis-paired cards.
// The model's prose was correct; the parser paired each title with the
// PREVIOUS line because `(?:^|\n)`-anchored patterns report m.index at the
// newline, shifting the line window back by one line.
const TRANSCRIPT = [
'Here are some films about Bitcoin:',
'',
'Documentaries:',
'',
'- The Rise and Rise of Bitcoin (2014) Early documentary following Bitcoin\'s emergence and community.',
'- Banking on Bitcoin (2016) Explores Bitcoin\'s origins and its potential to disrupt finance.',
'- Cryptopia (2020) Examines both the promise and pitfalls of crypto.',
].join('\n')
it('does not caption a title with the previous item\'s description', () => {
const series = extractAllTVSeries(TRANSCRIPT, 'recommend me some bitcoin series')
const banking = series.find(s => /Banking on Bitcoin/i.test(s.title))
if (banking?.synopsis) {
expect(banking.synopsis).not.toMatch(/Early documentary/i)
expect(banking.synopsis).toMatch(/Explores Bitcoin/i)
}
})
it('does not bleed a section header into the first card of a group', () => {
const series = extractAllTVSeries(TRANSCRIPT, 'recommend me some bitcoin series')
for (const s of series) {
expect(s.synopsis ?? '').not.toMatch(/Documentaries:/i)
}
})
it('keeps each item paired with its OWN description', () => {
const series = extractAllTVSeries(TRANSCRIPT, 'recommend me some bitcoin series')
const rise = series.find(s => /Rise and Rise/i.test(s.title))
if (rise?.synopsis) {
expect(rise.synopsis).toMatch(/Early documentary/i)
expect(rise.synopsis).not.toMatch(/Explores Bitcoin/i)
}
})
})
@@ -0,0 +1,34 @@
// Regression: operator phrasing must not be mistaken for content queries.
//
// Reported on-device 2026-08-06 — the content panel showed "Podcast
// recommendations" for a query that had nothing to do with podcasts. Both
// classifiers (this one and ChatPage's loader label) matched a bare `show`,
// which is how an operator phrases most requests to a node assistant:
// "show me my files", "show the logs", "show installed apps".
import { describe, it, expect } from 'vitest'
import { preferredFirstTab } from '../contentFiltering'
describe('preferredFirstTab — operator phrasing is not a content query', () => {
it('does not classify "show me ..." as a podcast query', () => {
expect(preferredFirstTab('show me my files')).not.toBe('podcast')
expect(preferredFirstTab('show the filebrowser logs')).not.toBe('podcast')
expect(preferredFirstTab('show installed apps')).not.toBe('podcast')
})
it('still classifies genuine podcast queries', () => {
expect(preferredFirstTab('recommend me a podcast')).toBe('podcast')
expect(preferredFirstTab('any good episode about bitcoin?')).toBe('podcast')
// NB: a bare "listen" belongs to the song rule, which is checked
// first — only the explicit "listen to a podcast" phrasing lands here.
expect(preferredFirstTab('listen to a podcast about bitcoin')).toBe('podcast')
})
it('keeps tv-show detection, which needs the two-word form', () => {
expect(preferredFirstTab('best tv show to binge')).toBe('tvshow')
})
it('still classifies films and music', () => {
expect(preferredFirstTab('recommend me 10 scifi films')).toBe('film')
expect(preferredFirstTab('good album for working')).toBe('song')
})
})
@@ -0,0 +1,18 @@
import { createApp, defineComponent } from 'vue'
/**
* Helper to test composables that use lifecycle hooks (onMounted, etc.)
* Creates a temporary Vue app, mounts it, runs the composable, and returns the result.
*/
export function withSetup<T>(composable: () => T): [T, ReturnType<typeof createApp>] {
let result!: T
const app = createApp(defineComponent({
setup() {
result = composable()
return () => null
},
}))
const div = document.createElement('div')
app.mount(div)
return [result, app]
}
@@ -0,0 +1,73 @@
/**
* 13-11: requestArchyLibrary (sibling of 13-06's requestArchyContent) and
* the init-time auto-trigger that fires both over the real bridge — the
* GAP-FOUND fix. 13-06 built requestArchyContent/content:request/
* content:push and unit-tested all of it, but nothing in the live UI ever
* called it (see 13-06-SUMMARY.md's Known Limitations); this pins that the
* fetch now fires from a real init-time event, not merely from a direct
* unit-test call.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
type PostedMessage = { type: string; kind?: string; scope?: string; [key: string]: unknown }
describe('useArchy: requestArchyLibrary + init-time auto-trigger (13-11)', () => {
let originalParent: Window
beforeEach(() => {
vi.resetModules()
originalParent = window.parent
Object.defineProperty(window, 'parent', {
value: { postMessage: vi.fn() },
writable: true,
configurable: true,
})
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = true
})
afterEach(() => {
Object.defineProperty(window, 'parent', {
value: originalParent,
writable: true,
configurable: true,
})
delete (window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__
vi.restoreAllMocks()
})
it('requestArchyLibrary sends a content:request with kind "library"', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
archy.init()
archy.requestArchyLibrary('own').catch(() => {})
expect(window.parent.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'content:request', kind: 'library', scope: 'own' }),
window.location.origin,
)
archy.destroy()
})
it('init() fires both requestArchyContent and requestArchyLibrary as a live init-time event — not merely callable (GAP-FOUND)', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
archy.init()
const postMessage = window.parent.postMessage as unknown as ReturnType<typeof vi.fn>
const contentRequests = postMessage.mock.calls
.map(([msg]) => msg as PostedMessage)
.filter((msg) => msg.type === 'content:request')
expect(contentRequests.some((msg) => msg.kind === 'all')).toBe(true)
expect(contentRequests.some((msg) => msg.kind === 'library')).toBe(true)
archy.destroy()
})
it('exposes requestArchyLibrary on the composable\'s returned API', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
expect(typeof archy.requestArchyLibrary).toBe('function')
})
})
@@ -0,0 +1,92 @@
// Regression pin for the stale-banner bug (operator-reported 2026-08-04):
// detail views are REUSED, not remounted, when a different item is selected in
// the content window. The composable's stage/apiUrl/primaryIndex used to
// survive that change, so the context-surface banner kept showing the previous
// item's artwork forever. These tests pin the identity-keyed reset and the
// stale-fetch generation guard.
import { describe, it, expect, vi } from 'vitest'
import { ref, nextTick } from 'vue'
import { useBannerFallback } from '../useBannerFallback'
function deferred<T>() {
let resolve!: (v: T) => void
const promise = new Promise<T>((r) => { resolve = r })
return { promise, resolve }
}
describe('useBannerFallback', () => {
it('resets to the new item\'s primary URL after the previous item resolved via API', async () => {
const title = ref('Item A')
const primaries = ref<(string | null)[]>([])
const apiFetch = vi.fn().mockResolvedValue({ posterUrl: 'https://api/a.jpg', backdropUrl: null })
const { bannerSrc } = useBannerFallback({
primaryUrls: () => primaries.value,
apiFetch,
title: () => title.value,
})
// Item A has no primaries — resolves through the API path.
await nextTick()
await vi.waitFor(() => expect(bannerSrc.value).toBe('https://api/a.jpg'))
// Select item B, which has its own primary URL. Before the fix, stage was
// stuck at 'api' and bannerSrc kept returning A's artwork.
title.value = 'Item B'
primaries.value = ['https://cdn/b-poster.jpg']
await nextTick()
await nextTick()
expect(bannerSrc.value).toBe('https://cdn/b-poster.jpg')
})
it('discards a stale API resolution that lands after the item changed', async () => {
const title = ref('Item A')
const primaries = ref<(string | null)[]>([])
const slow = deferred<{ posterUrl: string | null; backdropUrl: string | null }>()
const apiFetch = vi.fn().mockReturnValue(slow.promise)
const { bannerSrc } = useBannerFallback({
primaryUrls: () => primaries.value,
apiFetch,
title: () => title.value,
})
await nextTick()
// Item changes while A's fetch is still in flight.
title.value = 'Item B'
primaries.value = ['https://cdn/b-poster.jpg']
await nextTick()
await nextTick()
expect(bannerSrc.value).toBe('https://cdn/b-poster.jpg')
// A's fetch finally resolves — it must NOT stamp A's artwork over B.
slow.resolve({ posterUrl: 'https://api/a-late.jpg', backdropUrl: null })
await slow.promise
await nextTick()
expect(bannerSrc.value).toBe('https://cdn/b-poster.jpg')
})
it('re-runs the no-primary API kickoff for the new item after a reset', async () => {
const title = ref('Item A')
const primaries = ref<(string | null)[]>(['https://cdn/a.jpg'])
const apiFetch = vi.fn().mockResolvedValue({ posterUrl: 'https://api/b.jpg', backdropUrl: null })
const { bannerSrc } = useBannerFallback({
primaryUrls: () => primaries.value,
apiFetch,
title: () => title.value,
})
expect(bannerSrc.value).toBe('https://cdn/a.jpg')
expect(apiFetch).not.toHaveBeenCalled()
// New item with NO primaries — the kickoff must fire again post-reset.
title.value = 'Item B'
primaries.value = []
await nextTick()
await nextTick()
await vi.waitFor(() => expect(bannerSrc.value).toBe('https://api/b.jpg'))
expect(apiFetch).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,194 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useContentPanel } from '../useContentPanel'
import type { Film, Song, ImageItem, Place } from '@aiui/core/types/content'
describe('useContentPanel', () => {
let panel: ReturnType<typeof useContentPanel>
beforeEach(() => {
panel = useContentPanel()
panel.closePanel()
})
it('starts with panel closed', () => {
expect(panel.panelOpen.value).toBe(false)
expect(panel.availableTabs.value).toHaveLength(0)
})
it('setActiveTab only sets valid tabs', () => {
panel.availableTabs.value = ['film', 'song', 'prompt']
panel.setActiveTab('film')
expect(panel.activeTab.value).toBe('film')
panel.setActiveTab('song')
expect(panel.activeTab.value).toBe('song')
// Invalid tab should not change
panel.setActiveTab('podcast')
expect(panel.activeTab.value).toBe('song')
})
it('openFilmDetail sets selected film and clears others', () => {
const film = { id: 'f1', title: 'Test Film', year: 2025, genres: [], director: 'Dir' } as unknown as Film
panel.openFilmDetail(film)
expect(panel.selectedFilm.value).toEqual(film)
expect(panel.selectedSong.value).toBeNull()
expect(panel.selectedBook.value).toBeNull()
})
it('closeFilmDetail clears selection', () => {
const film = { id: 'f1', title: 'Test Film', year: 2025, genres: [], director: 'Dir' } as unknown as Film
panel.openFilmDetail(film)
panel.closeFilmDetail()
expect(panel.selectedFilm.value).toBeNull()
})
it('openSongDetail clears film selection', () => {
const film = { id: 'f1', title: 'Test Film', year: 2025, genres: [], director: 'Dir' } as unknown as Film
const song = { id: 's1', title: 'Test Song', artist: 'Artist', album: 'Album', genres: [] } as Song
panel.openFilmDetail(film)
panel.openSongDetail(song)
expect(panel.selectedSong.value).toEqual(song)
expect(panel.selectedFilm.value).toBeNull()
})
it('closePanel resets all state', () => {
const film = { id: 'f1', title: 'Test Film', year: 2025, genres: [], director: 'Dir' } as unknown as Film
panel.openFilmDetail(film)
panel.panelOpen.value = true
panel.availableTabs.value = ['film', 'prompt']
panel.closePanel()
expect(panel.panelOpen.value).toBe(false)
expect(panel.selectedFilm.value).toBeNull()
expect(panel.availableTabs.value).toHaveLength(0)
})
it('enterDesignSystemMode sets up design system tab', () => {
panel.enterDesignSystemMode()
expect(panel.panelOpen.value).toBe(true)
expect(panel.activeTab.value).toBe('design-system')
expect(panel.availableTabs.value).toEqual(['design-system'])
expect(panel.panelTitle.value).toBe('Design System')
})
it('openImageDetail and closeImageDetail work', () => {
const image = { id: 'i1', title: 'Test Image', url: 'https://example.com/img.jpg' } as ImageItem
panel.openImageDetail(image)
expect(panel.selectedImage.value).toEqual(image)
panel.closeImageDetail()
expect(panel.selectedImage.value).toBeNull()
})
// 13-11 (GAP-FOUND 2026-08-03): setArchyContent must make real,
// non-empty node content actually visible — populating panelFilms/
// panelSongs/panelPodcasts alone left the tab bar and panelOpen
// untouched, so real data sat in memory and never rendered.
describe('setArchyContent makes non-empty content actually appear', () => {
it('opens the panel and adds a song tab when songs are non-empty', () => {
const song = { id: 's1', title: 'Song', artist: 'Artist', album: 'Album', genres: [] } as Song
panel.setArchyContent({ songs: [song] })
expect(panel.panelOpen.value).toBe(true)
expect(panel.availableTabs.value).toContain('song')
expect(panel.availableTabs.value).toContain('prompt')
expect(panel.activeTab.value).toBe('song')
expect(panel.panelTitle.value).toBe('Song')
})
it('adds a film tab and a song tab together when both are non-empty', () => {
const film = { id: 'f1', title: 'Film', year: 2025, genres: [], director: 'Dir' } as unknown as Film
const song = { id: 's1', title: 'Song', artist: 'Artist', album: 'Album', genres: [] } as Song
panel.setArchyContent({ films: [film], songs: [song] })
expect(panel.availableTabs.value).toEqual(['film', 'song', 'prompt'])
expect(panel.activeTab.value).toBe('film')
})
it('does not force the panel open when the bundle is entirely empty', () => {
panel.closePanel()
panel.setArchyContent({ films: [], songs: [], podcasts: [] })
expect(panel.panelOpen.value).toBe(false)
expect(panel.availableTabs.value).toHaveLength(0)
// An empty delivery does NOT latch: nothing was supplied, so there is
// no node truth to protect and extraction must stay writable. The
// honest empty state is carried by the 'Nothing found' heading, not
// by the latch.
expect(panel.archyContentActive.value).toBe(false)
})
// Regression (live on archi-dev-box 2026-08-07): "show me paid for peer
// files" — the turn's surface carried 3 purchased images, the reply text
// was a plain markdown list matching no extraction pattern, and
// updatePanelFromText then set panelOpen from the regex-only tab list,
// CLOSING the panel setArchyContent had just opened. The probe showed
// surfaces=1 arriving; the user saw only prose.
it('keeps the panel open on node content when the reply text infers no tabs', () => {
const img = { id: 'i1', title: 'signal-test.jpeg', url: '/content/abc', mimeType: 'image/jpeg' } as unknown as ImageItem
panel.setArchyContent({ images: [img] })
expect(panel.panelOpen.value).toBe(true)
expect(panel.activeTab.value).toBe('image')
// The turn's prose answer arrives after the surfaces and fires the
// text path: a bullet list with no tags, no links, no patterns.
panel.updatePanelFromText(
"You've purchased 4 items from peer nodes:\n\n- signal-test.jpeg (170 KB) — 100 sats\n- Photos/got it!.jpg (253 KB) — 100 sats\n\nThese are mostly photos.",
'show me paid for peer files',
)
expect(panel.panelOpen.value).toBe(true)
expect(panel.availableTabs.value[0]).toBe('image')
expect(panel.panelImages.value).toHaveLength(1)
})
// Regression (operator report 2026-08-07): "recommend me 10 scifi films"
// — the node has no films, the model's reply carried [[film_ext:…]] tags,
// and the global archy latch (set at mount by the node's own images)
// meant extraction could never write panelFilms: ten recommendations
// rendered as bare prose beside an empty panel.
it('renders extracted recommendation previews in a bucket the node left empty', () => {
const img = { id: 'i1', title: 'photo.jpg', url: '/content/x', mimeType: 'image/jpeg' } as unknown as ImageItem
panel.setArchyContent({ images: [img], films: [], songs: [], podcasts: [] })
// The turn: the films scope came back empty, the reply carries tags.
panel.beginArchyContentLoad()
panel.setArchyContent({ films: [], songs: [], podcasts: [], images: [] })
panel.updatePanelFromText(
'Here are two sci-fi films:\n\n[[film_ext:Blade Runner 2049|2017|Denis Villeneuve]] — A stunning neo-noir sequel.\n\n[[film_ext:Arrival|2016|Denis Villeneuve]] — Thoughtful first contact.',
'recommend me sci-fi films',
)
expect(panel.panelFilms.value.map((f) => f.title)).toEqual(['Blade Runner 2049', 'Arrival'])
expect(panel.panelOpen.value).toBe(true)
expect(panel.availableTabs.value[0]).toBe('film')
})
it('never lets extracted tags overwrite a bucket the node actually filled', () => {
const song = { id: 's1', title: 'Real Node Song', artist: 'A', album: 'B', genres: [] } as Song
panel.setArchyContent({ songs: [song] })
panel.updatePanelFromText(
'You might also like [[song_ext:Fake Song|Nobody|2020]]',
'what music do I have',
)
expect(panel.panelSongs.value).toHaveLength(1)
expect(panel.panelSongs.value[0].title).toBe('Real Node Song')
})
it("keeps 'Nothing found' when the node and the reply both yield nothing", () => {
panel.closePanel()
panel.beginArchyContentLoad()
panel.setArchyContent({ films: [], songs: [], podcasts: [], images: [] })
expect(panel.panelTitle.value).toBe('Nothing found')
panel.updatePanelFromText('I could not find anything matching that on this node.', 'show me films')
expect(panel.panelTitle.value).toBe('Nothing found')
expect(panel.panelOpen.value).toBe(false)
})
})
it('openPlaceDetail and closePlaceDetail work', () => {
const place = { id: 'p1', name: 'Test Place', address: '123 St', lat: 0, lng: 0 } as Place
panel.openPlaceDetail(place)
expect(panel.selectedPlace.value).toEqual(place)
panel.closePlaceDetail()
expect(panel.selectedPlace.value).toBeNull()
})
})
@@ -0,0 +1,156 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// Mock Plyr before importing usePlayer
vi.mock('plyr', () => {
const MockPlyr = vi.fn().mockImplementation(() => ({
on: vi.fn(),
play: vi.fn().mockResolvedValue(undefined),
pause: vi.fn(),
destroy: vi.fn(),
currentTime: 0,
duration: 120,
}))
return { default: MockPlyr }
})
vi.mock('plyr/dist/plyr.css', () => ({}))
import { usePlayer } from '../usePlayer'
function makeSong(id: string, title = `Song ${id}`, artist = `Artist ${id}`) {
return {
id,
title,
artist,
album: 'Test Album',
genres: [],
}
}
describe('usePlayer', () => {
beforeEach(() => {
const player = usePlayer()
player.clearQueue()
vi.restoreAllMocks()
// Reset fetch mock
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
json: () => Promise.resolve({ source: 'test', type: 'stream', url: 'https://example.com/audio.mp3' }),
}))
})
it('returns expected API shape', () => { const player = usePlayer()
expect(player.currentSong).toBeDefined()
expect(player.isPlaying).toBeDefined()
expect(player.isLoading).toBeDefined()
expect(player.queue).toBeDefined()
expect(player.play).toBeTypeOf('function')
expect(player.pause).toBeTypeOf('function')
expect(player.toggle).toBeTypeOf('function')
expect(player.seek).toBeTypeOf('function')
expect(player.addToQueue).toBeTypeOf('function')
expect(player.playNext).toBeTypeOf('function')
expect(player.playPrevious).toBeTypeOf('function')
expect(player.clearQueue).toBeTypeOf('function')
})
it('plays a node-source song directly without touching Wavlake', async () => {
const fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
const player = usePlayer()
const song = {
...makeSong('n1', 'Node Track', 'Node Artist'),
sources: [{ type: 'funkwhale', name: 'This node', url: '/content/abc-123' }],
}
await player.play(song as never)
expect(fetchSpy).not.toHaveBeenCalled()
expect(player.isLoading.value).toBe(false)
expect(player.error.value).toBeNull()
expect(player.currentSong.value?.id).toBe('n1')
expect(player.playableSource.value?.url).toBe('/content/abc-123')
expect(player.playableSource.value?.source).toBe('node')
})
it('starts with no track', () => {
const player = usePlayer()
expect(player.hasTrack.value).toBe(false)
expect(player.currentSong.value).toBeNull()
expect(player.isPlaying.value).toBe(false)
})
it('addToQueue adds songs', () => {
const player = usePlayer()
const song1 = makeSong('1')
const song2 = makeSong('2')
player.addToQueue(song1)
player.addToQueue(song2)
expect(player.queue.value).toHaveLength(2)
expect(player.queue.value[0].id).toBe('1')
expect(player.queue.value[1].id).toBe('2')
})
it('addToQueue deduplicates by id', () => {
const player = usePlayer()
const song = makeSong('1')
player.addToQueue(song)
player.addToQueue(song)
expect(player.queue.value).toHaveLength(1)
})
it('removeFromQueue removes by index', () => {
const player = usePlayer()
player.addToQueue(makeSong('1'))
player.addToQueue(makeSong('2'))
player.addToQueue(makeSong('3'))
player.removeFromQueue(1)
expect(player.queue.value).toHaveLength(2)
expect(player.queue.value[0].id).toBe('1')
expect(player.queue.value[1].id).toBe('3')
})
it('clearQueue resets all state', () => {
const player = usePlayer()
player.addToQueue(makeSong('1'))
player.addToQueue(makeSong('2'))
player.clearQueue()
expect(player.queue.value).toHaveLength(0)
expect(player.currentIndex.value).toBe(-1)
expect(player.currentSong.value).toBeNull()
})
it('hasNext and hasPrevious compute correctly', () => {
const player = usePlayer()
player.addToQueue(makeSong('1'))
player.addToQueue(makeSong('2'))
player.addToQueue(makeSong('3'))
// At start, no previous
expect(player.hasPrevious.value).toBe(false)
// Move to middle
player.currentIndex.value = 1
expect(player.hasNext.value).toBe(true)
expect(player.hasPrevious.value).toBe(true)
// At end
player.currentIndex.value = 2
expect(player.hasNext.value).toBe(false)
expect(player.hasPrevious.value).toBe(true)
})
it('progress computes percentage from currentTime/duration', () => {
const player = usePlayer()
player.duration.value = 100
player.currentTime.value = 50
expect(player.progress.value).toBe(50)
player.currentTime.value = 0
expect(player.progress.value).toBe(0)
})
it('progress returns 0 when duration is 0', () => {
const player = usePlayer()
player.duration.value = 0
player.currentTime.value = 10
expect(player.progress.value).toBe(0)
})
})
@@ -0,0 +1,93 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { withSetup } from './testUtils'
import { useVisualViewport } from '../useVisualViewport'
// Helper to create a mock VisualViewport
function createMockViewport(height = 800) {
const listeners: Record<string, Set<EventListener>> = {}
return {
height,
width: 390,
offsetLeft: 0,
offsetTop: 0,
pageLeft: 0,
pageTop: 0,
scale: 1,
addEventListener: vi.fn((event: string, handler: EventListener) => {
if (!listeners[event]) listeners[event] = new Set()
listeners[event].add(handler)
}),
removeEventListener: vi.fn((event: string, handler: EventListener) => {
listeners[event]?.delete(handler)
}),
dispatchEvent: vi.fn(),
onresize: null,
onscroll: null,
_listeners: listeners,
_setHeight(h: number) {
this.height = h
// Fire resize listeners
listeners['resize']?.forEach(fn => fn(new Event('resize')))
},
}
}
describe('useVisualViewport', () => {
let mockVV: ReturnType<typeof createMockViewport>
beforeEach(() => {
vi.useFakeTimers()
mockVV = createMockViewport(800)
Object.defineProperty(window, 'visualViewport', { value: mockVV, configurable: true, writable: true })
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true })
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('returns expected API shape', () => {
const [result] = withSetup(() => useVisualViewport())
expect(result.keyboardHeight).toBeDefined()
expect(result.isKeyboardOpen).toBeDefined()
expect(result.viewportHeight).toBeDefined()
})
it('initializes viewportHeight from visualViewport', () => {
const [result] = withSetup(() => useVisualViewport())
expect(result.viewportHeight.value).toBe(800)
})
it('detects keyboard open when height shrinks significantly', () => {
const [result] = withSetup(() => useVisualViewport())
// Simulate keyboard opening (shrinks to 500px from 800px = 300px keyboard)
mockVV._setHeight(500)
vi.advanceTimersByTime(60) // past debounce
expect(result.isKeyboardOpen.value).toBe(true)
expect(result.keyboardHeight.value).toBe(300)
expect(result.viewportHeight.value).toBe(500)
})
it('does not trigger keyboard for small viewport changes', () => {
const [result] = withSetup(() => useVisualViewport())
// Small change (50px) — not keyboard
mockVV._setHeight(750)
vi.advanceTimersByTime(60)
expect(result.isKeyboardOpen.value).toBe(false)
expect(result.keyboardHeight.value).toBe(50)
})
it('debounces rapid viewport changes', () => {
const [result] = withSetup(() => useVisualViewport())
// Rapid changes — only last should apply
mockVV._setHeight(600)
vi.advanceTimersByTime(10)
mockVV._setHeight(550)
vi.advanceTimersByTime(10)
mockVV._setHeight(500)
vi.advanceTimersByTime(60)
expect(result.viewportHeight.value).toBe(500)
expect(result.keyboardHeight.value).toBe(300)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,267 @@
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'recipe' | 'news' | 'websites' | 'magazine' | 'code' | 'design-system' | 'nostr' | 'app' | 'favorites' | 'discover' | 'prompt'
export interface MagazineSection {
title: string
content: string
imageUrl?: string
author?: string
url?: string
group?: string
}
// ─── Query classifiers ───────────────────────────────────────────
export function isNewsQuery(q: string): boolean {
const lower = q.toLowerCase().trim()
if (!lower) return false
return /\b(news|latest|recent|current|what'?s happening|updates? about|headlines?|breaking|press|media coverage)\b/.test(lower) ||
/what'?s the latest|latest \w+ news/.test(lower) ||
/what are people saying|what'?s the word|what do people think/.test(lower) ||
/what happened (today|this week|recently|yesterday)|any updates? on|what'?s (new|going on)|trending|in the news/i.test(lower) ||
/current events|today'?s top|catch me up|brief me|fill me in/i.test(lower)
}
export function isNewsLikeResponse(text: string): boolean {
const lower = text.toLowerCase()
return /for instant .* news|check these sources|for (the )?latest (bitcoin )?news|direct sources/i.test(lower) ||
/(have )?access to (live )?web search|want me to (go back and )?search/i.test(lower) ||
/i can'?t access the web.{0,30}(but|however)|having trouble reaching the web.{0,30}(but|however)|unable to browse.{0,30}(but|however)|can'?t search the web.{0,30}(but|however)/i.test(lower) ||
/here are.{0,20}(reliable|trusted|good) sources|top sources for/i.test(lower)
}
export function isMusicQuery(q: string): boolean {
if (!q) return false
return /\b(song|songs|music|track|tracks|playlist|playlists|album|albums|listen|listening|sing|singing|singer|singers|band|bands|artist|artists|rapper|rappers|rap|hip hop|r&b|rock|jazz|classical|edm|electronic|pop music|concert|concerts|vinyl|soundtrack|anthem|beat|beats|melody|melodies|tune|tunes|lyric|lyrics|acoustic|remix|dj|genre|genres|spotify|soundcloud|bandcamp|musician|musicians|composer|composers|orchestra|symphony|punk|metal|reggae|blues|soul|funk|country music|grammys?|billboard|top 40|mixtape|ep\b|lp\b|discography|jam|jams|banger|bangers)\b/i.test(q) ||
/recommend.*(song|music|track|listen)/i.test(q) ||
/play\s+(me\s+)?(some|a)\b/i.test(q) ||
/what genre|favorite (song|music|band|artist|jam|tune)|best (song|album|track|music)/i.test(q)
}
export function isWebsitesQuery(q: string): boolean {
const lower = q.toLowerCase().trim()
return /\b(website|websites|where to check|best places? to check|places? to (look|check|find)|check online|resources?|sources? to (check|read|visit)|links?|urls?|sites?|portals?|platforms? for|tools? for|apps? for|services? for)\b/.test(lower) ||
/where (can i|should i) (check|look|find|go|visit|browse)/.test(lower) ||
/point me to|direct me to|link me|send me (to|a link|some links)|any good (sites|resources|tools|platforms)/i.test(lower)
}
export function isWebsitesLikeResponse(text: string): boolean {
const lower = text.toLowerCase()
return /best places? to check|check online yourself|places? to check online|websites? to (visit|check|read)/i.test(lower) ||
/here are (some|a few|the best) (resources|websites|sites|links|tools|platforms)/i.test(lower) ||
/i'?d recommend (checking|visiting|looking at)|you (can|could|should|might) (check|visit|try|look at|browse)/i.test(lower) ||
/useful (resources|websites|sites|links|tools)|helpful (resources|websites|sites|links)/i.test(lower)
}
export function isBookQuery(q: string): boolean {
return /\b(book|books|read|reading|novel|novels|author|authors|nonfiction|non-fiction|recommend.*read|must.read|literature|memoir|memoirs|biography|biographies|autobiography|paperback|hardcover|kindle|audible|audiobook|audiobooks|bookshelf|bestseller|bestsellers|goodreads|epub)\b/i.test(q) ||
/what should i read|favorite reads?|reading list|book club|book recommendation|suggest.*book|what.*worth reading|good reads?|anything to read|currently reading/i.test(q)
}
export function isBookLikeResponse(text: string): boolean {
return /\b(novel|author|pages?|ISBN|published|bestsell|literary|fiction|nonfiction|book)\b/i.test(text) &&
(text.match(/\bby\s+[A-Z]/g)?.length ?? 0) >= 1
}
export function isTVQuery(q: string): boolean {
return /\b(tv\b|tv shows?|tv series|series|television|streaming|binge|watch|recommend.*shows?|best shows?|seasons?|netflix|hbo|hulu|disney\+?|apple tv|amazon prime|peacock|paramount\+?|showtime|miniseries|docuseries|sitcom|drama series|limited series|pilot|showrunner|renewed|cancelled|premiere)\b/i.test(q) ||
/what'?s good on|anything to (binge|watch)|what should (i|we) (watch|stream)|good (shows?|series) to|new (shows?|series)|best (shows?|series)|recommend.*(shows?|series|watch)/i.test(q)
}
export function isImageQuery(q: string): boolean {
return /\b(image|images|photo|photos|picture|pictures|screenshot|screenshots|gallery|artwork|illustration|visual|infographic|diagram|chart)\b/i.test(q)
}
export function isPlaceQuery(q: string): boolean {
return /\b(restaurant|restaurants|place|places|food|eat|eating|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizza|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|where to eat|good food|best food|where should i eat|recommend.*eat|recommend.*restaurant|recommend.*place|hungry|starving|takeout|take-?out|delivery|reservation|reservations|michelin|yelp|zagat|foodie|gastropub|tapas|dim sum|bbq|barbecue|food truck|brewery|winery|cocktail bar|speakeasy|rooftop bar|happy hour)\b/i.test(q) ||
/where.*(eat|food|drink|grab|dine)|best.*(brunch|lunch|dinner|food|restaurant|eat|spot)|good.*(food|restaurant|eat|spot)|what'?s good to eat/i.test(q)
}
export function isPlaceLikeResponse(text: string): boolean {
return /\b(restaurant|cuisine|menu|reserv|dining|address|open|hours|price range|\$\$|\$\$\$|michelin|yelp|rating)\b/i.test(text) &&
(text.match(/\b(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill)\b/gi)?.length ?? 0) >= 2
}
// ─── Recipe classifiers ─────────────────────────────────────────
export function isRecipeQuery(q: string): boolean {
if (!q) return false
return /\b(recipe|recipes|cook|cooking|bake|baking|meal|meals|dish|dishes|ingredient|ingredients|how to make|how to cook|how to bake)\b/i.test(q) ||
/make me a|cook me|bake me|recipe for|what can i (make|cook|bake)|meal prep|meal plan/i.test(q)
}
export function isRecipeLikeResponse(text: string): boolean {
return /<recipe_ext\s/i.test(text)
}
// ─── Code classifiers ───────────────────────────────────────────
export function isCodeQuery(q: string): boolean {
if (!q) return false
return /\b(code|coding|programming|function|algorithm|implement|debug|syntax|API|library|framework|snippet|script|regex|refactor|compile|runtime|variable|class|method|module|package|dependency|typescript|javascript|python|rust|go|java|swift|kotlin|ruby|php|css|html|sql|bash|shell|cli|terminal|git|docker|webpack|vite|npm|yarn|pnpm)\b/i.test(q) ||
/write (me )?a |how (do i|to) (code|program|implement|build|create|make)|show me.*code|code (example|sample)|fix.*(bug|error|issue)|what does this code/i.test(q)
}
export function isCodeLikeResponse(text: string): boolean {
const codeBlockCount = (text.match(/```[\s\S]*?```/g) ?? []).length
return codeBlockCount >= 3
}
// ─── Nostr classifiers ──────────────────────────────────────────
export function isNostrQuery(q: string): boolean {
if (!q) return false
return /\b(nostr|npub[a-z0-9]{8,}|nip-?\d+|damus|primal|snort|amethyst|coracle|iris|nos\.social|zaps?\b|relays?\b|naddr|nevent|nprofile|note1[a-z0-9]+|nostrich|fiatjaf)\b/i.test(q) ||
/\b(social media|social network|social protocol)\b.*\b(decentrali|censorship|relay|open)\b/i.test(q) ||
/decentralized social|censorship.resistant.*(social|network|protocol)/i.test(q)
}
export function isNostrLikeResponse(text: string): boolean {
// Literal "nostr" is always sufficient
if (/\bnostr\b/i.test(text)) return true
// Otherwise require 2+ distinct Nostr-specific signals
const signals = [
/\bnpub[a-z0-9]{8,}\b/i,
/\bnip-?\d+\b/i,
/\b(damus|primal|snort|amethyst|coracle|iris|nos\.social|nostrudel)\b/i,
/\b(relay|relays)\b.*\b(wss?:\/\/|connect|publish)\b/i,
/\bzaps?\b.*\b(lightning|sats|send)\b/i,
/\bnote1[a-z0-9]+\b/i,
/\bnevent[a-z0-9]+\b/i,
/\bnprofile[a-z0-9]+\b/i,
/\bnostrich\b/i,
/\bfiatjaf\b/i,
]
return signals.filter(re => re.test(text)).length >= 2
}
// ─── App classifiers ────────────────────────────────────────────
export function isAppQuery(q: string): boolean {
if (!q) return false
return /\b(app|apps|application|applications|client|clients|wallet|wallets|tool|tools|software|download|install)\b/i.test(q) &&
/\b(best|good|recommend|suggest|which|what|top|favorite|popular|use|try|need)\b/i.test(q) ||
/what app|which app|best app|recommend.*app|suggest.*app|best.*client|best.*wallet|recommend.*wallet|recommend.*tool/i.test(q) ||
/what.*(use|download|install) for|how (do i|to) (use|get|install|set up)/i.test(q)
}
export function isAppLikeResponse(text: string): boolean {
const lower = text.toLowerCase()
const appSignals = [
/popular (clients?|apps?|wallets?|tools?) include/i,
/you (can|could|might|should) (use|try|check out|download|install)/i,
/available (on|for) (ios|android|web|desktop|mac|windows|linux)/i,
/download (from|on|at)/i,
/(app store|play store|google play|f-?droid|github releases?)/i,
/open.?source.*(app|client|tool|wallet)/i,
]
return appSignals.filter(re => re.test(lower)).length >= 2
}
// ─── Tab filtering ────────────────────────────────────────────────
export function extractQueryContext(q: string): string {
const stop = /\b(what|is|are|the|a|an|latest|recent|current|news|about|for|how|why|when|where|can|could|should|would|tell|me|please|best|good)\b/gi
const cleaned = q.replace(stop, ' ').replace(/\s+/g, ' ').trim().slice(0, 60)
return cleaned || ''
}
export function preferredFirstTab(userQuery: string): ContentTab | null {
const q = userQuery.toLowerCase().trim()
// `films` (plural) was missing: "recommend me 10 scifi films" — the
// operator's own words — matched nothing and opened no content tab.
if (/\b(film|films|movie|movies)\b/.test(q)) return 'film'
// Podcast is checked BEFORE song because its words are the specific
// ones: "listen to a podcast" must not be swallowed by the song rule's
// bare `listen`. Conversely `show` is NOT a podcast word — it is how
// operators phrase almost every request ("show me my files"), and it
// made unrelated queries render "Podcast recommendations". `tv show`
// keeps its own two-word form in the tvshow rule below.
if (/\b(podcast|episode)\b/.test(q)) return 'podcast'
if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song'
if (/\b(book|books|read|reading|novel|author|nonfiction|non-fiction)\b/.test(q)) return 'book'
if (/\b(tv\b|tv show|tv series|series|television|streaming|binge|watch)\b/.test(q)) return 'tvshow'
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizza|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|hungry)\b/.test(q)) return 'place'
if (isRecipeQuery(q)) return 'recipe'
if (isCodeQuery(q)) return 'code'
if (isAppQuery(q)) return 'app'
if (isNostrQuery(q)) return 'nostr'
if (isNewsQuery(q)) return 'news'
if (isWebsitesQuery(q)) return 'websites'
return null
}
export function filterTabsByContext(
userQuery: string,
hasFilms: boolean,
hasSongs: boolean,
hasPodcasts: boolean,
hasBooks: boolean,
hasTVSeries: boolean,
hasImages: boolean,
hasPlaces: boolean,
hasNews: boolean,
hasWebsites: boolean,
hasMagazine: boolean,
hasNostr: boolean,
hasApps: boolean,
hasCode = false,
hasRecipes = false,
): ContentTab[] {
const q = userQuery.toLowerCase().trim()
const preferred = preferredFirstTab(userQuery)
// Build the full set of detected content tabs
const all: ContentTab[] = []
if (hasFilms) all.push('film')
if (hasBooks) all.push('book')
if (hasTVSeries) all.push('tvshow')
if (hasImages) all.push('image')
if (hasPlaces) all.push('place')
if (hasRecipes) all.push('recipe')
if (hasSongs) all.push('song')
if (hasPodcasts) all.push('podcast')
if (hasCode) all.push('code')
if (hasApps) all.push('app')
if (hasMagazine) all.push('magazine')
if (hasNews) all.push('news')
if (hasWebsites) all.push('websites')
if (hasNostr) all.push('nostr')
// Helper: prioritize certain tabs first, then append remaining detected content
const prioritize = (priority: ContentTab[]): ContentTab[] => {
const present = priority.filter(t => all.includes(t))
const rest = all.filter(t => !present.includes(t))
return [...present, ...rest]
}
// Nostr query → prioritize nostr tab
if (isNostrQuery(q)) {
return prioritize(['nostr', 'app', 'magazine', 'websites'])
}
// App query → prioritize apps tab
if (isAppQuery(q)) {
const result = prioritize(['app', 'nostr', 'magazine', 'websites'])
return result.length > 0 ? result : hasNostr ? ['nostr'] : []
}
// News query → prioritize news/magazine tabs
if (isNewsQuery(q)) {
return prioritize(['magazine', 'news', 'websites', 'podcast'])
}
if (hasMagazine && all.length === 1) {
return ['magazine']
}
if (hasWebsites && all.length === 1) {
return ['websites']
}
if (preferred && all.includes(preferred)) {
const rest = all.filter((t) => t !== preferred)
return [preferred, ...rest]
}
return all
}
+865
View File
@@ -0,0 +1,865 @@
import { ref, computed } from 'vue'
import { useChatStore } from '@/stores/chat'
import { searchWeb } from '@/composables/useWebSearch'
import { getApiKey } from '@/utils/key-vault'
import type { ImageAttachment } from '@aiui/core/types/message'
import { usePersonaStore } from '@/stores/personas'
import { useMemoryStore } from '@/stores/memory'
import { useArchy } from '@/composables/useArchy'
import { archyBridge, type ChatSurface } from '@/services/archyBridge'
import { useContentPanel } from '@/composables/useContentPanel'
import type { Film, Song, Podcast, ImageItem } from '@aiui/core/types/content'
import { useCodeContext } from '@/composables/useCodeContext'
import { apiFetch } from '@/utils/api-fetch'
import { useSettingsStore } from '@/stores/settings'
type Provider = 'claude' | 'openrouter' | 'mock'
// API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/)
const BASE = import.meta.env.BASE_URL || '/'
const CLAUDE_PATH = `${BASE}api/claude/v1/messages`
const OPENROUTER_PATH = `${BASE}api/openrouter`
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
import { mockPodcasts } from '@/mocks/podcasts'
// Demo-site content pack (operator decision 2026-08-07): mock libraries are
// presented as "the user's library" ONLY in demo/dev builds. In production
// these constant-fold to empty strings, the prompt's library block vanishes,
// and the mock modules drop out of the bundle entirely.
const filmContext = (import.meta.env.DEV || import.meta.env.VITE_DEMO_CONTENT === 'true') ? mockFilms.map((f) =>
`- [${f.id}] "${f.title}" (${f.year}) dir. ${f.director} | ${f.genres.join(', ')} | ${f.rating}/10 | On: ${f.sources.map(s => s.type).join(', ')}`
).join('\n') : ''
const songContext = (import.meta.env.DEV || import.meta.env.VITE_DEMO_CONTENT === 'true') ? mockSongs.map((s) =>
`- [${s.id}] "${s.title}" by ${s.artist}${s.album ? ` (${s.album})` : ''}${s.year ? ` (${s.year})` : ''} | ${(s.genres ?? []).join(', ')} | On: ${(s.sources ?? []).map(x => x.type).join(', ')}`
).join('\n') : ''
const podcastContext = (import.meta.env.DEV || import.meta.env.VITE_DEMO_CONTENT === 'true') ? mockPodcasts.map((p) =>
`- [${p.id}] "${p.title}" by ${p.host ?? 'Unknown'}${p.year ? ` (${p.year})` : ''} | ${(p.genres ?? []).join(', ')} | On: ${p.sources.map(x => x.type).join(', ')}`
).join('\n') : ''
const librarySection = (import.meta.env.DEV || import.meta.env.VITE_DEMO_CONTENT === 'true')
? `\nThe user's film library:\n${filmContext}\n\nThe user's song library:\n${songContext}\n\nThe user's podcast library:\n${podcastContext}`
: ''
// ─── Wavlake catalog context (fetched at runtime) ────────────
interface WavlakeCatalogTrack {
title?: string
artist?: string
albumTitle?: string
duration?: number
}
const wavlakeCatalog = ref<WavlakeCatalogTrack[]>([])
let wavlakeFetchedAt = 0
const WAVLAKE_REFRESH_INTERVAL = 30 * 60 * 1000 // 30 minutes
async function refreshWavlakeCatalog() {
if (Date.now() - wavlakeFetchedAt < WAVLAKE_REFRESH_INTERVAL && wavlakeCatalog.value.length > 0) return
try {
const BASE = import.meta.env.BASE_URL || '/'
const res = await apiFetch(`${BASE}api/music/rankings?days=30&limit=40`)
if (!res.ok) return
const data = await res.json()
if (Array.isArray(data)) {
wavlakeCatalog.value = data.map((t: Record<string, unknown>) => ({
title: t.title as string,
artist: t.artist as string,
albumTitle: t.albumTitle as string | undefined,
duration: t.duration as number | undefined,
}))
wavlakeFetchedAt = Date.now()
}
} catch {
// Silently fail — catalog is optional context
}
}
function buildWavlakeContext(): string {
if (wavlakeCatalog.value.length === 0) return ''
const lines = wavlakeCatalog.value.map((t) =>
`- "${t.title}" by ${t.artist}${t.albumTitle ? ` (${t.albumTitle})` : ''}`
)
return `\n\n**Wavlake trending tracks** (these are confirmed playable — prefer recommending from this list when relevant):\n${lines.join('\n')}`
}
const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library (films, songs, and podcasts).
**News/Factual queries:** When the user asks for "news", "latest", "recent", or current information, lead with a direct answer summarizing the news/facts. You MAY add "For deeper coverage:" with [[podcast_ext:...]] tags only. Do NOT use [[song_ext:...]] or [[film_ext:...]] for news queries—podcasts are the appropriate follow-up. Never substitute an answer with only recommendations.
**Films:** When recommending or discussing films from the user's library, use [[film:ID]] where ID is the film's id. For films NOT in the library, use [[film_ext:Title|Year|Director]], e.g. [[film_ext:Brokeback Mountain|2005|Ang Lee]]. Write a brief reason why the film is worth watching on the same line as the tag.
**Songs:** When recommending or discussing songs, ALWAYS use tags for every song you mention:
- Library songs: [[song:ID]] where ID is the song's id (e.g. [[song:s1]]).
- Other songs: [[song_ext:Title|Artist|Year]] (year optional), e.g. [[song_ext:Never Meant|American Football|1999]].
Never list songs in plain text only—each recommendation must have a tag so the UI can show playable cards.
**Podcasts:** When recommending or discussing podcasts, use tags:
- Library podcasts: [[podcast:ID]] where ID is the podcast's id (e.g. [[podcast:p1]]).
- Other podcasts: [[podcast_ext:Title|Host|Year]] (year optional), e.g. [[podcast_ext:What Bitcoin Did|Peter McCormack|2018]].
Prioritize Podcasting 2.0friendly platforms: Fountain.fm, Podcast Index, Castopod, Odysee, Rumble, YouTube, Podverse.
**Books:** When recommending or discussing books, use [[book_ext:Title|Author|Year]], e.g. [[book_ext:Neuromancer|William Gibson|1984]]. Write a brief reason why the book is worth reading on the same line.
**TV Series:** When recommending or discussing TV series/shows, use [[tv_ext:Title|Year|Creator]], e.g. [[tv_ext:Breaking Bad|2008|Vince Gilligan]]. Do NOT use [[film_ext:...]] for TV series — use [[tv_ext:...]] instead. Write a brief reason why the show is worth watching on the same line.
**Places/Restaurants:** When recommending restaurants, cafes, bars, or other places to visit, use [[place_ext:Name|Cuisine|City|Rating|PriceLevel|Address]], e.g. [[place_ext:Sushi Nakazawa|Japanese|New York|4.7|3|23 Commerce St]]. Rating is out of 5, PriceLevel is 1-4 ($ to $$$$). Omit fields you don't know. Write a brief description on the same line.
**Apps/Tools:** When recommending apps, clients, wallets, or tools, use [[app_ext:Name|Category|Platforms|URL]], e.g. [[app_ext:Damus|nostr-client|iOS|https://damus.io]]. Categories: nostr-client, lightning-wallet, bitcoin-wallet, privacy, node, dev-tool. Platforms: comma-separated list of ios,android,web,desktop,cli. Write a brief description on the same line.
**Images:** When sharing or describing images, use standard markdown image syntax: ![Description](https://image-url). Include a brief caption.
**Websites / "Best places to check":** When listing resources, places to check online, or websites for the user to visit, use markdown links: [Name](https://full-url). For simple domains use **Name** (domain.com), e.g. **Bitcoin Mailing List** (gnusha.org).
**Music discovery:** All music plays from **Wavlake** — a Lightning-powered, Nostr-native music platform. When recommending songs, prefer tracks from the Wavlake trending list (provided below) since those are confirmed playable. For genre requests, use [[song_ext:Title|Artist]] tags — the UI will search Wavlake automatically. Songs not on Wavlake won't play, so stick to Wavlake artists when you can. The user can zap (tip) artists with Lightning directly through the platform.
Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out.
${librarySection}`
const activeProvider = ref<Provider>('claude')
const activeModel = ref('claude-haiku-4.5')
// One-shot signal a send/regenerate/edit failure looked like a missing or
// invalid API key (or an unreachable proxy) rather than a transient/server
// error — consumed by ChatWindow.vue to auto-open Settings so the user isn't
// left in a dead end with no obvious next step. Deliberately narrow (401/403,
// explicit "api key"/"unauthorized" text, or a connection-level failure to
// reach the proxy at all) so a rate-limited or momentarily-flaky provider
// response does NOT send the user to Settings for a problem Settings can't
// fix. Reset to false by the consumer immediately after acting on it, so it
// behaves as a pulse rather than sticky state (each new failure can re-fire).
const needsApiKey = ref(false)
function looksLikeMissingApiKey(err: string): boolean {
const lower = err.toLowerCase()
return (
/\b(401|403)\b/.test(err) ||
lower.includes('api key') ||
lower.includes('x-api-key') ||
lower.includes('unauthorized') ||
lower.includes('authentication_error') ||
lower.includes('failed to fetch') ||
lower.includes('econnrefused') ||
lower.includes(' 502') ||
lower.includes(' 503')
)
}
const availableProviders = computed(() => {
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
{
id: 'claude',
name: 'Claude (Max)',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' },
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4' },
{ id: 'claude-opus-4', name: 'Claude Opus 4' },
],
},
]
providers.push({
id: 'openrouter',
name: 'OpenRouter',
models: [
{ id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick' },
{ id: 'qwen/qwen3-235b-a22b-thinking-2507', name: 'Qwen3 235B Thinking' },
{ id: 'mistralai/mistral-small-3.1-24b-instruct:free', name: 'Mistral Small 3.1 (free)' },
{ id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)' },
],
})
providers.push({
id: 'mock',
name: 'Local (no API)',
models: [{ id: 'echo', name: 'Echo (mirror input)' }],
})
return providers
})
function setProvider(provider: Provider) {
activeProvider.value = provider
const p = availableProviders.value.find((pp) => pp.id === provider)
if (p && p.models.length > 0) {
activeModel.value = p.models[0].id
}
}
function setModel(model: string) {
activeModel.value = model
}
interface ChatMessage {
role: 'user' | 'assistant'
content: string
images?: ImageAttachment[]
}
/** Build Claude API content array for a message (multimodal when images present) */
function buildClaudeContent(msg: ChatMessage): string | Array<Record<string, unknown>> {
const text = msg.content || '...'
if (!msg.images || msg.images.length === 0) return text
const blocks: Array<Record<string, unknown>> = []
for (const img of msg.images) {
blocks.push({
type: 'image',
source: { type: 'base64', media_type: img.mediaType, data: img.data },
})
}
blocks.push({ type: 'text', text })
return blocks
}
/**
* Sanitize message history for the Claude API:
* - Ensure every message has non-empty content
* - Merge consecutive same-role messages to enforce strict alternation
* - Guarantees the resulting array is valid for Claude's messages API
*/
function sanitizeHistory(messages: ChatMessage[]): ChatMessage[] {
const result: ChatMessage[] = []
for (const msg of messages) {
const content = msg.content && msg.content.trim().length > 0 ? msg.content : '...'
const sanitized: ChatMessage = { role: msg.role, content, images: msg.images }
if (result.length > 0 && result[result.length - 1].role === sanitized.role) {
// Merge into previous message of same role to maintain alternation
const prev = result[result.length - 1]
prev.content = prev.content + '\n' + sanitized.content
if (sanitized.images && sanitized.images.length > 0) {
prev.images = [...(prev.images ?? []), ...sanitized.images]
}
} else {
result.push(sanitized)
}
}
return result
}
async function streamMock(
messages: ChatMessage[],
onToken: (text: string) => void,
signal?: AbortSignal,
): Promise<void> {
const lastUser = messages.filter((m) => m.role === 'user').pop()
const text = lastUser
? `You said: "${lastUser.content}"\n\nThis is AIUI in echo mode. Select Claude or OpenRouter from the model picker.`
: 'Hello! I am AIUI running in mock mode.'
for (const char of text) {
if (signal?.aborted) return
onToken(char)
await new Promise((r) => setTimeout(r, 12))
}
}
interface GenerationParams {
temperature?: number
maxTokens?: number
topP?: number
stopSequences?: string[]
}
async function streamClaude(
messages: ChatMessage[],
onToken: (text: string) => void,
onError: (err: string) => void,
systemPrompt: string,
webSearch: boolean,
signal?: AbortSignal,
params?: GenerationParams,
): Promise<void> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
// Use the user's own key when enabled: memory-only store ref first, then
// the encrypted vault. The key is never read from localStorage (S2).
const settingsStore = useSettingsStore()
if (settingsStore.settings.useOwnApiKey) {
const ownKey = settingsStore.claudeApiKey || await getApiKey('claude')
if (ownKey) {
headers['x-api-key'] = ownKey
}
} else {
const vaultKey = await getApiKey('claude')
if (vaultKey) {
headers['x-api-key'] = vaultKey
}
}
// Build API messages — sanitize to ensure valid alternation and non-empty content
const apiMessages = messages.map(m => ({
role: m.role,
content: buildClaudeContent(m),
}))
const body: Record<string, unknown> = {
model: activeModel.value,
system: systemPrompt,
messages: apiMessages,
stream: true,
webSearch,
}
if (params?.temperature !== undefined) body.temperature = params.temperature
if (params?.maxTokens !== undefined) body.max_tokens = params.maxTokens
if (params?.topP !== undefined) body.top_p = params.topP
if (params?.stopSequences && params.stopSequences.length > 0) body.stop_sequences = params.stopSequences
const res = await apiFetch(CLAUDE_PATH, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal,
})
if (!res.ok) {
const body = await res.text().catch(() => 'Could not read error body')
onError(`Claude proxy error ${res.status}: ${body}`)
return
}
await readSSE(res, (data) => {
try {
const parsed = JSON.parse(data)
if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
onToken(parsed.delta.text)
} else if (parsed.type === 'error') {
onError(parsed.error?.message ?? 'Claude stream error')
}
} catch { /* malformed SSE chunk */ }
}, onError, signal)
}
async function streamOpenRouter(
messages: ChatMessage[],
onToken: (text: string) => void,
onError: (err: string) => void,
systemPrompt: string,
signal?: AbortSignal,
): Promise<void> {
const orMessages = [
{ role: 'system' as const, content: systemPrompt },
...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })),
]
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'AIUI',
}
// Use vault key if available, otherwise proxy handles auth
const vaultKey = await getApiKey('openrouter')
if (vaultKey) {
headers['Authorization'] = `Bearer ${vaultKey}`
}
const res = await apiFetch(OPENROUTER_PATH, {
method: 'POST',
headers,
body: JSON.stringify({
model: activeModel.value,
messages: orMessages,
stream: true,
}),
signal,
})
if (!res.ok) {
const body = await res.text().catch(() => 'Could not read error body')
onError(`OpenRouter error ${res.status}: ${body}`)
return
}
await readSSE(res, (data) => {
if (data === '[DONE]') return
try {
const parsed = JSON.parse(data)
const delta = parsed.choices?.[0]?.delta?.content
if (delta) onToken(delta)
} catch { /* malformed SSE chunk */ }
}, onError, signal)
}
/**
* Embedded-mode chat delegation (D-01/D-17): when AIUI is running inside
* Archy, the model call, the tool-calling loop, and the model key all live
* node-side. This sends only the latest user turn over the existing
* origin-checked postMessage bridge (`archyBridge.sendChat`) and emits the
* node's final answer as a single token — there is no streaming across the
* bridge in this tracer, only a one-shot `chat:request`/`chat:response`
* round trip.
*/
async function streamViaArchy(
messages: ChatMessage[],
onToken: (text: string) => void,
onError: (err: string) => void,
signal?: AbortSignal,
): Promise<void> {
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
const text = lastUser?.content ?? ''
const { beginArchyContentLoad, setArchyContent } = useContentPanel()
beginArchyContentLoad()
try {
const result = await archyBridge.sendChat(text)
if (signal?.aborted) return
// The turn's tool results, rendered — not just described. A node that
// listed twelve shared files used to produce a correct paragraph next
// to an empty grid, because this was the point the structured results
// were dropped.
setArchyContent(mergeChatSurfaces(result.surfaces))
onToken(result.text)
} catch (err) {
if (signal?.aborted) return
// Clear the 'Loading…' heading — an errored turn must not leave the
// panel claiming it is still working.
setArchyContent({})
onError(err instanceof Error ? err.message : 'Archy chat request failed')
}
}
/**
* Flatten a turn's surfaces into the one bundle the panel renders. A
* single turn can legitimately run `content_list` more than once (own
* files AND peer films, say); concatenating rather than letting the last
* call win is what keeps both visible.
*/
function mergeChatSurfaces(surfaces: ChatSurface[] = []) {
return {
films: surfaces.flatMap((s) => (s.bundle?.films ?? []) as Film[]),
songs: surfaces.flatMap((s) => (s.bundle?.songs ?? []) as Song[]),
podcasts: surfaces.flatMap((s) => (s.bundle?.podcasts ?? []) as Podcast[]),
images: surfaces.flatMap((s) => (s.bundle?.images ?? []) as ImageItem[]),
}
}
async function readSSE(
res: Response,
onData: (data: string) => void,
onError: (err: string) => void,
signal?: AbortSignal,
): Promise<void> {
const reader = res.body?.getReader()
if (!reader) {
onError('No response body')
return
}
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
if (signal?.aborted) {
reader.cancel()
return
}
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || !trimmed.startsWith('data: ')) continue
const payload = trimmed.slice(6)
if (payload === '[DONE]') return
try {
onData(payload)
} catch {
// skip malformed chunks
}
}
}
} catch (err) {
if (signal?.aborted) return
onError(err instanceof Error ? err.message : 'Stream read error')
} finally {
reader.cancel().catch(() => {})
}
}
function formatWebSearchContext(results: { title: string; url: string; content?: string }[]): string {
if (results.length === 0) return ''
const lines = results.map((r, i) => {
const snippet = r.content ? `${r.content.slice(0, 200)}${r.content.length > 200 ? '…' : ''}` : ''
return `${i + 1}. [${r.title}](${r.url})${snippet}`
})
return `\n\n**Web search results (PRIORITIZE these):**
- Answer the user's question using these results. Cite sources.
- You MAY add [[podcast_ext:...]] or [[film_ext:...]] tags for "to learn more" recommendations after your answer.\n\n${lines.join('\n')}`
}
/** Build the system prompt, incorporating persona and memory */
function buildSystemPrompt(chatStore: ReturnType<typeof useChatStore>): string {
let prompt = SYSTEM_PROMPT
// Prepend persona system prompt if active
const personaId = chatStore.activeConversation?.personaId
if (personaId) {
const personaStore = usePersonaStore()
const persona = personaStore.getPersona(personaId)
if (persona?.systemPrompt) {
prompt = persona.systemPrompt + '\n\n' + prompt
}
}
// Append Wavlake catalog context
prompt += buildWavlakeContext()
// Append memory facts
const memoryStore = useMemoryStore()
prompt += memoryStore.buildMemoryContext()
if (chatStore.webSearchEnabled) {
prompt += `
**Web search:** You have access to WebSearch and WebFetch tools. Use them to look up current information, news, and facts when the user asks. You can search the web and fetch page content. Web search is enabled for this session—do not tell the user it is unavailable.`
}
// Append Archy node context when running embedded in Archipelago
const archy = useArchy()
prompt += archy.buildArchyContext()
// Append code context when in code mode
const code = useCodeContext()
if (code.isCodeMode.value) {
const sections: string[] = []
if (code.activeProject.value) {
sections.push(`**Active project:** ${code.activeProject.value.name} (${code.activeProject.value.language ?? 'Unknown'})`)
}
if (code.selectedDesignTokens.value.length > 0) {
sections.push(`**Selected design tokens:** ${code.selectedDesignTokens.value.join(', ')}`)
}
if (code.selectedFiles.value.length > 0) {
sections.push(`**Selected files:** ${code.selectedFiles.value.join(', ')}`)
}
if (code.activeFileContent.value && code.activeFile.value) {
const content = code.activeFileContent.value.slice(0, 2000)
sections.push(`**Open file (${code.activeFile.value}):**\n\`\`\`${code.activeFileLanguage.value}\n${content}\n\`\`\``)
}
if (sections.length > 0) {
prompt += `\n\n**Code Context:**\n${sections.join('\n')}`
}
}
return prompt
}
function getConversationParams(chatStore: ReturnType<typeof useChatStore>): GenerationParams {
const conv = chatStore.activeConversation
if (!conv) return {}
return {
temperature: conv.temperature,
maxTokens: conv.maxTokens,
topP: conv.topP,
stopSequences: conv.stopSequences,
}
}
let currentAbort: AbortController | null = null
/** Background title generation after first exchange */
async function generateAutoTitle(conversationId: string) {
const chatStore = useChatStore()
const conv = chatStore.conversations.get(conversationId)
if (!conv) return
// Only auto-title after first exchange (1 user + 1 assistant message)
if (conv.messages.length !== 2) return
const userMsg = conv.messages[0]
if (userMsg.role !== 'user') return
// Skip if title was manually set (not auto-generated from first message)
const autoTitle = userMsg.content.slice(0, 60) + (userMsg.content.length > 60 ? '...' : '')
if (conv.title !== autoTitle) return
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const vaultKey = await getApiKey('claude')
if (vaultKey) headers['x-api-key'] = vaultKey
const res = await apiFetch(CLAUDE_PATH, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'claude-haiku-4.5',
system: 'You generate very short conversation titles. Respond with ONLY a 3-5 word title, no quotes, no punctuation at the end.',
messages: [{ role: 'user', content: `Title this conversation: "${userMsg.content.slice(0, 200)}"` }],
max_tokens: 20,
stream: false,
}),
})
if (!res.ok) return
const data = await res.json()
const title = data?.content?.[0]?.text?.trim()
if (title && title.length > 0 && title.length < 60) {
conv.title = title
conv.updatedAt = Date.now()
}
} catch {
// Silent fail — title stays as default
}
}
/** Stream a specific provider/model — used by comparison mode */
export async function streamWithModel(
provider: string,
model: string,
messages: { role: string; content: string }[],
onToken: (text: string) => void,
onError: (err: string) => void,
signal: AbortSignal,
): Promise<void> {
const history = messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content }))
const savedModel = activeModel.value
activeModel.value = model
try {
if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal)
} else if (provider === 'claude') {
await streamClaude(history, onToken, onError, 'You are a helpful assistant.', false, signal)
} else if (provider === 'openrouter') {
await streamOpenRouter(history, onToken, onError, 'You are a helpful assistant.', signal)
} else {
await streamMock(history, onToken, signal)
}
} finally {
activeModel.value = savedModel
}
}
export function useAI() {
const chatStore = useChatStore()
// Fetch Wavlake catalog on first use (non-blocking)
refreshWavlakeCatalog()
function stopGeneration() {
if (currentAbort) {
currentAbort.abort()
currentAbort = null
}
chatStore.isStreaming = false
}
async function sendMessage(userText: string, images?: ImageAttachment[]) {
// Refresh Wavlake catalog if stale (non-blocking, fire-and-forget)
refreshWavlakeCatalog()
const provider = activeProvider.value
currentAbort = new AbortController()
const signal = currentAbort.signal
let convId = chatStore.activeConversationId
if (!convId) {
convId = chatStore.createConversation()
}
const cid = convId
chatStore.addMessage(cid, {
role: 'user',
content: userText,
images: images && images.length > 0 ? images : undefined,
})
const assistantMsg = chatStore.addMessage(cid, { role: 'assistant', content: '' })
if (!assistantMsg) return
chatStore.isStreaming = true
let systemPrompt = buildSystemPrompt(chatStore)
let clientSearchSucceeded = false
// Embedded in Archy, the NODE owns the tool loop and `streamViaArchy`
// sends only the user's text — this system prompt, and anything folded
// into it, is never transmitted. So a client-side search here cost a
// round trip and a CSP console error on every single turn while its
// results provably reached no model. Web search for the embedded path
// belongs node-side, next to the other tools.
if (chatStore.webSearchEnabled && userText.trim() && !archyBridge.isInArchy()) {
const results = await searchWeb(userText)
if (results.length > 0) {
systemPrompt += formatWebSearchContext(results)
chatStore.setMessageWebResults(cid, assistantMsg.id, results)
clientSearchSucceeded = true
console.log('[AIUI] Injected', results.length, 'web search results into context')
} else {
console.warn('[AIUI] Web search enabled but 0 results — proxy will handle search')
}
}
// If client-side search succeeded, don't ask the proxy to search again
const proxyWebSearch = chatStore.webSearchEnabled && !clientSearchSucceeded
const history: ChatMessage[] = sanitizeHistory(
chatStore.messages
.filter((m) => m.id !== assistantMsg.id)
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, images: m.images }))
)
const onToken = (text: string) => chatStore.appendToLastMessage(cid, text)
const onError = (err: string) => {
console.error(`[AIUI ${provider}]`, err)
chatStore.appendToLastMessage(cid, `${err}`)
if (provider !== 'mock' && looksLikeMissingApiKey(err)) needsApiKey.value = true
}
const genParams = getConversationParams(chatStore)
try {
if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal)
} else if (provider === 'claude') {
await streamClaude(history, onToken, onError, systemPrompt, proxyWebSearch, signal, genParams)
} else if (provider === 'openrouter') {
await streamOpenRouter(history, onToken, onError, systemPrompt, signal)
} else {
await streamMock(history, onToken, signal)
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
const msg = err instanceof Error ? err.message : String(err)
console.error(`[AIUI] Connection error:`, err)
chatStore.appendToLastMessage(cid, `\n\n⚠ Connection error: ${msg}`)
if (provider !== 'mock' && looksLikeMissingApiKey(msg)) needsApiKey.value = true
} finally {
currentAbort = null
chatStore.isStreaming = false
}
// Auto-title: generate a short title after first exchange
generateAutoTitle(cid)
}
/**
* Edit a user message and regenerate the AI response.
* Clears all messages after the edited message, then re-sends.
*/
async function editAndResend(messageId: string, newContent: string) {
const convId = chatStore.activeConversationId
if (!convId) return
const conv = chatStore.activeConversation
if (!conv) return
const msgIndex = conv.messages.findIndex((m) => m.id === messageId)
if (msgIndex === -1) return
// Update the message content
chatStore.updateMessageContent(convId, messageId, newContent)
// Delete all messages after this one
chatStore.deleteMessagesAfter(convId, msgIndex + 1)
// Re-send (creates new assistant message and streams)
await resendLastUserMessage()
}
/**
* Regenerate the last assistant response.
* Deletes the last assistant message and re-sends with the same user message.
*/
async function regenerateLastResponse() {
const convId = chatStore.activeConversationId
if (!convId) return
const conv = chatStore.activeConversation
if (!conv || conv.messages.length === 0) return
// Find the last assistant message and remove it
const lastIndex = conv.messages.length - 1
if (conv.messages[lastIndex].role === 'assistant') {
chatStore.deleteMessagesAfter(convId, lastIndex)
}
await resendLastUserMessage()
}
/** Internal: re-send from the current last user message */
async function resendLastUserMessage() {
const convId = chatStore.activeConversationId
if (!convId) return
const conv = chatStore.activeConversation
if (!conv || conv.messages.length === 0) return
const lastUserMsg = [...conv.messages].reverse().find((m) => m.role === 'user')
if (!lastUserMsg) return
const provider = activeProvider.value
currentAbort = new AbortController()
const signal = currentAbort.signal
const cid = convId
const assistantMsg = chatStore.addMessage(cid, { role: 'assistant', content: '' })
if (!assistantMsg) return
chatStore.isStreaming = true
let systemPrompt = buildSystemPrompt(chatStore)
if (chatStore.webSearchEnabled && lastUserMsg.content.trim() && !archyBridge.isInArchy()) {
const results = await searchWeb(lastUserMsg.content)
if (results.length > 0) {
systemPrompt += formatWebSearchContext(results)
chatStore.setMessageWebResults(cid, assistantMsg.id, results)
}
}
const history: ChatMessage[] = sanitizeHistory(
chatStore.messages
.filter((m) => m.id !== assistantMsg.id)
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, images: m.images }))
)
const onToken = (text: string) => chatStore.appendToLastMessage(cid, text)
const onError = (err: string) => {
console.error(`[AIUI ${provider}]`, err)
chatStore.appendToLastMessage(cid, `${err}`)
if (provider !== 'mock' && looksLikeMissingApiKey(err)) needsApiKey.value = true
}
const genParams = getConversationParams(chatStore)
try {
if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal)
} else if (provider === 'claude') {
await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled, signal, genParams)
} else if (provider === 'openrouter') {
await streamOpenRouter(history, onToken, onError, systemPrompt, signal)
} else {
await streamMock(history, onToken, signal)
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
const msg = err instanceof Error ? err.message : String(err)
chatStore.appendToLastMessage(cid, `\n\n⚠ Connection error: ${msg}`)
if (provider !== 'mock' && looksLikeMissingApiKey(msg)) needsApiKey.value = true
} finally {
currentAbort = null
chatStore.isStreaming = false
}
}
return {
sendMessage,
stopGeneration,
editAndResend,
regenerateLastResponse,
activeProvider,
activeModel,
availableProviders,
setProvider,
setModel,
needsApiKey,
}
}
@@ -0,0 +1,488 @@
import { ref, readonly } from 'vue'
import { archyBridge } from '@/services/archyBridge'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
import type { Film, Song, Podcast, ImageItem } from '@aiui/core/types/content'
import {
mockArchyApps, mockArchySystem, mockArchyNetwork,
mockArchyWallet, mockArchyBitcoin, mockArchyFiles,
} from '@/mocks/archy'
type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files' | 'bitcoin'
interface ArchyApp {
id: string
name: string
state: string
status: string
}
interface ArchySystemInfo {
version?: string
name?: string
}
interface ArchyNetworkInfo {
connected?: boolean
}
export interface ArchyWalletInfo {
available?: boolean
status?: string
alias?: string
num_active_channels?: number
num_peers?: number
synced_to_chain?: boolean
block_height?: number
balance_sats?: number
channel_balance_sats?: number
pending_open_balance?: number
message?: string
}
export interface ArchyFileEntry {
name: string
path: string
size?: number
modified?: string
type: 'file' | 'folder'
}
export interface ArchyBitcoinInfo {
available: boolean
block_height?: number
sync_progress?: number
chain?: string
mempool_tx_count?: number
mempool_size?: number
}
// Singleton reactive state (shared across all components using this composable)
const isEmbedded = ref(false)
const isInitialized = ref(false)
const permissions = ref<AIContextCategory[]>([])
const accentColor = ref<string | null>(null)
const installedApps = ref<ArchyApp[]>([])
const systemInfo = ref<ArchySystemInfo>({})
const networkInfo = ref<ArchyNetworkInfo>({})
const walletInfo = ref<ArchyWalletInfo>({})
const fileList = ref<ArchyFileEntry[]>([])
const bitcoinInfo = ref<ArchyBitcoinInfo>({ available: false })
let cleanups: (() => void)[] = []
/**
* Reactive composable wrapping archyBridge for Archy ↔ AIUI integration.
* Call `init()` once in App.vue when `?embedded=true` is detected.
*/
export function useArchy() {
/** Initialize the bridge and start listening for Archy messages */
function init() {
if (isInitialized.value) return
const embedded = !!(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__
isEmbedded.value = embedded
// Dev mock mode: load realistic Archy data for standalone testing
const useMock = import.meta.env.VITE_MOCK_ARCHY === 'true' ||
new URLSearchParams(window.location.search).has('mockArchy')
if (useMock && !embedded) {
isInitialized.value = true
isEmbedded.value = true
permissions.value = ['apps', 'system', 'network', 'wallet', 'bitcoin', 'files']
installedApps.value = mockArchyApps as unknown as ArchyApp[]
systemInfo.value = mockArchySystem
networkInfo.value = mockArchyNetwork
walletInfo.value = mockArchyWallet
bitcoinInfo.value = mockArchyBitcoin
fileList.value = mockArchyFiles
console.log('[AIUI] Mock Archy data loaded for dev testing')
return
}
if (!embedded || !archyBridge.isInArchy()) return
archyBridge.init()
isInitialized.value = true
// Listen for permission updates
const unsubPerms = archyBridge.onPermissionsUpdate((cats) => {
permissions.value = cats
// Auto-fetch context for newly permitted categories
fetchPermittedContext(cats)
})
cleanups.push(unsubPerms)
// Listen for theme updates — Archy always reports mode:'dark' today, but
// honor whatever it sends rather than hardcoding that assumption here;
// App.vue's mount-time isEmbeddedFlag check already forces dark
// immediately without waiting for this round trip, so this is a
// corroborating update for whenever it does arrive.
const unsubTheme = archyBridge.onThemeUpdate((theme) => {
accentColor.value = theme.accent
applyAccentColor(theme.accent)
useTheme().setTheme(theme.mode)
})
cleanups.push(unsubTheme)
// Request theme on init
archyBridge.requestTheme()
// 13-11 (GAP-FOUND 2026-08-03): fire the content/library fetch from a
// real init-time event instead of leaving requestArchyContent /
// requestArchyLibrary merely callable with nothing in the live UI ever
// calling them — 13-06 built the whole content:request/content:push
// machinery and unit-tested it end to end, but nothing in ChatPage.vue's
// render tree (or anywhere else) ever invoked it, so real node content
// never appeared no matter how green the tests were (13-06's own Known
// Limitations). `init()` runs once per embedded session (guarded by
// `isInitialized` above) and is itself triggered by App.vue mounting —
// a real UI event, not a user-typed phrase. Fire-and-forget: both
// functions already resolve to a silent, logged no-op when the user
// hasn't granted Media/File access (`permitted: false`), so this never
// throws into `init()`.
void requestArchyAllContent()
void requestArchyLibrary('own')
}
/** Fetch context for all permitted categories */
async function fetchPermittedContext(cats: AIContextCategory[]) {
const fetches: Promise<void>[] = []
function fetchCategory<T>(cat: AIContextCategory, setter: (data: T) => void, validator: (data: unknown) => boolean = () => true) {
return archyBridge.requestContext(cat).then((res) => {
if (!res.permitted) {
console.warn(`[AIUI Archy] ${cat}: not permitted — user should enable in Archy Settings`)
return
}
if (res.data && validator(res.data)) {
setter(res.data as T)
}
}).catch((err) => {
console.warn(`[AIUI Archy] ${cat} fetch failed:`, err?.message ?? err)
})
}
if (cats.includes('apps')) {
fetches.push(fetchCategory('apps', (data) => { installedApps.value = data as ArchyApp[] }, Array.isArray))
}
if (cats.includes('system')) {
fetches.push(fetchCategory('system', (data) => { systemInfo.value = data as ArchySystemInfo }))
}
if (cats.includes('network')) {
fetches.push(fetchCategory('network', (data) => { networkInfo.value = data as ArchyNetworkInfo }))
}
if (cats.includes('wallet')) {
fetches.push(fetchCategory('wallet', (data) => { walletInfo.value = data as ArchyWalletInfo }))
}
if (cats.includes('bitcoin')) {
fetches.push(fetchCategory('bitcoin', (data) => { bitcoinInfo.value = data as ArchyBitcoinInfo }))
}
if (cats.includes('files')) {
fetches.push(fetchCategory('files', (data) => { fileList.value = data as ArchyFileEntry[] }, Array.isArray))
}
await Promise.all(fetches)
}
/** Refresh context data (call when user returns to chat) */
async function refreshContext() {
if (!isInitialized.value) return
await fetchPermittedContext(permissions.value)
}
/** Request Archy to perform an action */
async function requestAction(action: string, params: Record<string, string> = {}) {
if (!isInitialized.value) return { success: false, error: 'Not initialized' }
return archyBridge.requestAction(action, params)
}
/** Read a file's text content via FileBrowser */
async function readFile(path: string): Promise<{ content: string; truncated: boolean; size: number } | null> {
const res = await requestAction('read-file', { path })
const data = (res as unknown as Record<string, unknown>).data
if (res.success && data) {
return data as { content: string; truncated: boolean; size: number }
}
return null
}
/** Tail recent logs for an app */
async function tailLogs(appId: string, lines = 50): Promise<string[] | null> {
const res = await requestAction('tail-logs', { appId, lines: String(lines) })
const data = (res as unknown as Record<string, unknown>).data
if (res.success && data) {
return (data as { lines: string[] }).lines
}
return null
}
/**
* Request a batch of grid-ready content from Archy (D-12/D-14, AIUI-03)
* and hand it to `useContentPanel`'s `setArchyContent`, which is the
* source of truth for `FilmGrid`/`SongGrid` once a node has supplied it.
* Mirrors `archyBridge.requestContext`'s permitted/not-permitted shape —
* no new convention.
*/
async function requestArchyContent(
kind: 'films' | 'songs' | 'podcasts' | 'all' = 'all',
scope: 'own' | 'peers' | 'owned' = 'own',
) {
if (!isInitialized.value) return
try {
const res = await archyBridge.requestArchyContent(kind, scope)
if (!res.permitted) {
console.warn('[AIUI Archy] content: not permitted — user should enable Media/File access in Archy Settings')
return
}
const panel = useContentPanel()
panel.setArchyContent({
films: res.films as Film[],
songs: panel.panelSongs.value,
podcasts: res.podcasts as Podcast[],
images: res.images as ImageItem[],
})
} catch (err) {
console.warn('[AIUI Archy] content fetch failed:', (err as Error)?.message ?? err)
}
}
/**
* Load every content scope the node can offer, merged into one grid.
*
* `own` alone was all init ever asked for, which meant a films search showed
* nothing but this node's own shared files: **IndeeHub and anything else
* purchased live in `owned` (`content.owned-list`), and other nodes' catalogs
* in `peers` — and neither scope had a single caller anywhere in the app.**
* They existed only as type-signature options.
*
* Scopes are fetched concurrently and merged once, rather than each calling
* `setArchyContent` itself: that sink REPLACES films/podcasts, so three
* separate pushes would leave only whichever resolved last. Deduped by id
* because the same title can legitimately appear in more than one scope
* (owned locally and offered by a peer).
*
* A failing scope must not cost the others — a dead or slow peer is normal,
* not exceptional — so each is caught individually and contributes nothing.
*/
async function requestArchyAllContent() {
if (!isInitialized.value) return
// PROGRESSIVE, not all-at-once. The first version awaited all three scopes
// together, so the grid waited on the slowest — and `peers` browses every
// federated node over FIPS (falling back to Tor), which routinely takes
// tens of seconds or times out entirely when a peer is offline. On-device
// that showed up as "content(peers) failed: Content request timed out" and
// an AIUI that felt very slow to open, with NOTHING rendered in the
// meantime even though local content was ready immediately.
//
// So: paint `own` as soon as it lands, then fold in the slower sources as
// they arrive. A scope that times out costs only its own results.
const films: Film[] = []
const podcasts: Podcast[] = []
const images: ImageItem[] = []
const seen = new Set<string>()
const merge = (res: { films?: unknown; podcasts?: unknown; images?: unknown } | null) => {
if (!res) return false
let added = false
for (const f of (res.films ?? []) as Film[]) {
const k = `film:${f.id}`
if (seen.has(k)) continue
seen.add(k); films.push(f); added = true
}
for (const p of (res.podcasts ?? []) as Podcast[]) {
const k = `pod:${p.id}`
if (seen.has(k)) continue
seen.add(k); podcasts.push(p); added = true
}
for (const im of (res.images ?? []) as ImageItem[]) {
const k = `img:${im.id}`
if (seen.has(k)) continue
seen.add(k); images.push(im); added = true
}
return added
}
const paint = () => {
const panel = useContentPanel()
panel.setArchyContent({
films: [...films],
songs: panel.panelSongs.value,
podcasts: [...podcasts],
images: [...images],
})
}
const fetchScope = (scope: 'own' | 'owned' | 'peers') =>
archyBridge
.requestArchyContent('all', scope)
.then((res) => {
if (!res.permitted) {
console.warn(`[AIUI Archy] content(${scope}): not permitted — enable Media/File access in Archy Settings`)
return null
}
return res
})
.catch((err) => {
// A slow or absent peer is ordinary, not exceptional.
console.warn(`[AIUI Archy] content(${scope}) failed:`, (err as Error)?.message ?? err)
return null
})
// Local content first — it is fast and is what the operator sees instantly.
if (merge(await fetchScope('own'))) paint()
// Then the slower sources, each painting as it lands rather than blocking
// the others or the initial render.
await Promise.all(
(['owned', 'peers'] as const).map((scope) =>
fetchScope(scope).then((res) => {
if (merge(res)) paint()
}),
),
)
}
/**
* Request the node's real music library (13-11 — the D-13 wave) and hand
* it to the same `setArchyContent` sink `requestArchyContent` uses, so
* `SongGrid`'s `songs` bucket fills exactly the way the films bucket
* already does. Sibling of `requestArchyContent`, same bridge call, same
* permitted/not-permitted shape — the `'library'` kind is what routes
* this node-side to `music.list-tracks` (real tag-extracted metadata)
* instead of `content.*` (see `archyBridge.ts`'s `requestArchyContent`
* doc comment). `films`/`podcasts` are never touched by a library
* request — only `songs` is meaningful for `kind: 'library'`, so this
* merges into whatever films/podcasts `setArchyContent` last held rather
* than clobbering them with empty arrays.
*/
async function requestArchyLibrary(scope: 'own' | 'peers' | 'owned' = 'own') {
if (!isInitialized.value) return
try {
const res = await archyBridge.requestArchyContent('library', scope)
if (!res.permitted) {
console.warn('[AIUI Archy] library: not permitted — user should enable Media/File access in Archy Settings')
return
}
const panel = useContentPanel()
panel.setArchyContent({
films: panel.panelFilms.value,
songs: res.songs as Song[],
podcasts: panel.panelPodcasts.value,
})
} catch (err) {
console.warn('[AIUI Archy] library fetch failed:', (err as Error)?.message ?? err)
}
}
/** Apply accent color as CSS custom property */
function applyAccentColor(color: string) {
document.documentElement.style.setProperty('--color-accent', color)
}
/** Build context string for AI system prompt */
function buildArchyContext(): string {
if (!isInitialized.value) return ''
const sections: string[] = []
if (permissions.value.includes('apps') && installedApps.value.length > 0) {
const appList = installedApps.value
.map((a) => `- ${a.name} (${a.state}${a.status ? ', ' + a.status : ''})`)
.join('\n')
sections.push(`**Installed apps on this node:**\n${appList}\nYou can view recent app logs by requesting the tail-logs action with an appId.`)
}
if (permissions.value.includes('system') && systemInfo.value.name) {
const sys = systemInfo.value
sections.push(`**System:** ${sys.name}${sys.version ? ' v' + sys.version : ''}`)
}
if (permissions.value.includes('network')) {
const net = networkInfo.value
sections.push(`**Network:** ${net.connected ? 'Connected' : 'Disconnected'}`)
}
if (permissions.value.includes('wallet') && walletInfo.value.available) {
const w = walletInfo.value
const parts: string[] = []
if (w.alias) parts.push(w.alias)
if (w.num_active_channels !== undefined) parts.push(`${w.num_active_channels} channels`)
if (w.num_peers !== undefined) parts.push(`${w.num_peers} peers`)
if (w.balance_sats !== undefined) parts.push(`On-chain: ${w.balance_sats.toLocaleString()} sats`)
if (w.channel_balance_sats !== undefined) parts.push(`In channels: ${w.channel_balance_sats.toLocaleString()} sats`)
if (w.synced_to_chain !== undefined) parts.push(w.synced_to_chain ? 'synced' : 'syncing')
sections.push(`**Lightning (LND):** ${parts.join(' | ')}`)
}
if (permissions.value.includes('bitcoin') && bitcoinInfo.value.available) {
const btc = bitcoinInfo.value
const syncPct = btc.sync_progress ? (btc.sync_progress * 100).toFixed(2) + '%' : 'unknown'
const parts = [`Block ${btc.block_height?.toLocaleString() ?? '?'}`, `${syncPct} synced`]
if (btc.chain) parts.push(btc.chain)
if (btc.mempool_tx_count) parts.push(`mempool: ${btc.mempool_tx_count.toLocaleString()} txs`)
sections.push(`**Bitcoin:** ${parts.join(', ')}`)
}
if (permissions.value.includes('files') && fileList.value.length > 0) {
const files = fileList.value
const folders = files.filter(f => f.type === 'folder')
const fileItems = files.filter(f => f.type === 'file')
const images = fileItems.filter(f => /\.(jpg|jpeg|png|gif|webp|svg|heic|heif)$/i.test(f.name))
const videos = fileItems.filter(f => /\.(mp4|mkv|avi|mov|webm)$/i.test(f.name))
const music = fileItems.filter(f => /\.(mp3|flac|wav|ogg|m4a|aac|opus)$/i.test(f.name))
const docs = fileItems.filter(f => /\.(pdf|doc|docx|txt|md|ods|xlsx|csv)$/i.test(f.name))
const parts: string[] = [`${files.length} items`]
if (folders.length > 0) parts.push(`${folders.length} folders (${folders.map(f => f.name).join(', ')})`)
if (images.length > 0) parts.push(`${images.length} images`)
if (videos.length > 0) parts.push(`${videos.length} videos`)
if (music.length > 0) parts.push(`${music.length} audio files`)
if (docs.length > 0) parts.push(`${docs.length} documents`)
const recent = fileItems.slice(0, 15).map(f => f.name).join(', ')
sections.push(`**Files:** ${parts.join(' | ')}\nRecent: ${recent}\nYou can read file contents by requesting the read-file action with a file path.`)
}
if (sections.length === 0) return ''
return `\n\n**Archy Node Context** (this user is running AIUI on their Archipelago node):\n${sections.join('\n')}\n\nYou can help the user manage their node, check service status, browse files, and recommend apps. Available actions: open an app (open-app), install an app (install-app), tail app logs (tail-logs), read a file (read-file), navigate in Archy (navigate). When recommending apps, use [[app_ext:...]] tags and check if they're already installed. When discussing the user's files, mention specific files you can see. If the user asks about their photos, videos, or music, reference the file counts above.`
}
/** Clean up on component unmount */
function destroy() {
for (const cleanup of cleanups) cleanup()
cleanups = []
archyBridge.destroy()
isInitialized.value = false
}
return {
isEmbedded: readonly(isEmbedded),
isInitialized: readonly(isInitialized),
permissions: readonly(permissions),
accentColor: readonly(accentColor),
installedApps: readonly(installedApps),
systemInfo: readonly(systemInfo),
networkInfo: readonly(networkInfo),
walletInfo: readonly(walletInfo),
fileList: readonly(fileList),
bitcoinInfo: readonly(bitcoinInfo),
init,
destroy,
refreshContext,
requestAction,
readFile,
tailLogs,
requestArchyContent,
requestArchyAllContent,
requestArchyLibrary,
buildArchyContext,
}
}
@@ -0,0 +1,145 @@
import { ref, computed } from 'vue'
import type { Message } from '@aiui/core/types/message'
export interface AudioExportOptions {
voiceIndex: number
rate: number
includeMusic: boolean
onlyAssistant: boolean
}
export function useAudioExport() {
const isPlaying = ref(false)
const isPaused = ref(false)
const currentIndex = ref(0)
const progress = ref(0)
const availableVoices = ref<SpeechSynthesisVoice[]>([])
const utterance = ref<SpeechSynthesisUtterance | null>(null)
const isSupported = computed(() => 'speechSynthesis' in window)
function loadVoices() {
if (!isSupported.value) return
const synth = window.speechSynthesis
availableVoices.value = synth.getVoices()
if (availableVoices.value.length === 0) {
synth.onvoiceschanged = () => {
availableVoices.value = synth.getVoices()
}
}
}
function play(messages: Message[], options: AudioExportOptions) {
if (!isSupported.value) return
const synth = window.speechSynthesis
const filtered = options.onlyAssistant
? messages.filter((m) => m.role === 'assistant')
: messages
if (filtered.length === 0) return
isPlaying.value = true
isPaused.value = false
currentIndex.value = 0
function speakNext(index: number) {
if (index >= filtered.length || !isPlaying.value) {
stop()
return
}
currentIndex.value = index
progress.value = (index / filtered.length) * 100
const msg = filtered[index]
const text = msg.content.replace(/```[\s\S]*?```/g, 'code block').replace(/[#*_`]/g, '')
const utt = new SpeechSynthesisUtterance(text)
utt.rate = options.rate
if (availableVoices.value[options.voiceIndex]) {
utt.voice = availableVoices.value[options.voiceIndex]
}
utt.onend = () => {
speakNext(index + 1)
}
utt.onerror = () => {
speakNext(index + 1)
}
utterance.value = utt
synth.speak(utt)
}
speakNext(0)
}
function pause() {
if (!isSupported.value) return
window.speechSynthesis.pause()
isPaused.value = true
}
function resume() {
if (!isSupported.value) return
window.speechSynthesis.resume()
isPaused.value = false
}
function stop() {
if (!isSupported.value) return
window.speechSynthesis.cancel()
isPlaying.value = false
isPaused.value = false
currentIndex.value = 0
progress.value = 0
utterance.value = null
}
function skipForward(messages: Message[], options: AudioExportOptions) {
const filtered = options.onlyAssistant
? messages.filter((m) => m.role === 'assistant')
: messages
if (currentIndex.value < filtered.length - 1) {
window.speechSynthesis.cancel()
play(messages, { ...options })
// Advance to next
currentIndex.value = Math.min(currentIndex.value + 1, filtered.length - 1)
}
}
async function exportAsWav(messages: Message[], options: AudioExportOptions): Promise<Blob | null> {
if (!isSupported.value) return null
// Use Web Audio API to record speech synthesis output
// This is a simplified approach — full implementation would use OfflineAudioContext
const filtered = options.onlyAssistant
? messages.filter((m) => m.role === 'assistant')
: messages
const fullText = filtered.map((m) => m.content.replace(/```[\s\S]*?```/g, '').replace(/[#*_`]/g, '')).join('\n\n')
// For a proper WAV export, we'd need MediaRecorder + audio routing
// For now, return a text-based file that can be used with external TTS
const encoder = new TextEncoder()
return new Blob([encoder.encode(fullText)], { type: 'text/plain' })
}
return {
isSupported,
isPlaying,
isPaused,
currentIndex,
progress,
availableVoices,
loadVoices,
play,
pause,
resume,
stop,
skipForward,
exportAsWav,
}
}
@@ -0,0 +1,114 @@
export interface DetectedBitcoinAddress {
address: string
type: 'bech32' | 'bech32m' | 'p2pkh' | 'p2sh' | 'testnet'
}
export interface DetectedLightningInvoice {
invoice: string
amount?: number
description?: string
}
export interface DetectedBolt12Offer {
offer: string
}
export interface DetectedTxId {
txid: string
}
// Bitcoin address patterns
const BECH32_REGEX = /\b(bc1q[a-z0-9]{38,62})\b/gi
const BECH32M_REGEX = /\b(bc1p[a-z0-9]{38,62})\b/gi
const P2PKH_REGEX = /\b(1[13456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{25,34})\b/g
const P2SH_REGEX = /\b(3[13456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{25,34})\b/g
const TESTNET_REGEX = /\b(tb1[a-z0-9]{38,62})\b/gi
// Lightning patterns
const BOLT11_REGEX = /\b(lnbc[a-z0-9]+)\b/gi
const BOLT12_REGEX = /\b(lno1[a-z0-9]+)\b/gi
// Transaction ID (64 hex chars)
const TXID_REGEX = /\b([a-f0-9]{64})\b/gi
export function detectBitcoinAddresses(text: string): DetectedBitcoinAddress[] {
const addresses: DetectedBitcoinAddress[] = []
const seen = new Set<string>()
for (const match of text.matchAll(BECH32_REGEX)) {
if (!seen.has(match[1].toLowerCase())) {
seen.add(match[1].toLowerCase())
addresses.push({ address: match[1], type: 'bech32' })
}
}
for (const match of text.matchAll(BECH32M_REGEX)) {
if (!seen.has(match[1].toLowerCase())) {
seen.add(match[1].toLowerCase())
addresses.push({ address: match[1], type: 'bech32m' })
}
}
for (const match of text.matchAll(P2PKH_REGEX)) {
if (!seen.has(match[1])) {
seen.add(match[1])
addresses.push({ address: match[1], type: 'p2pkh' })
}
}
for (const match of text.matchAll(P2SH_REGEX)) {
if (!seen.has(match[1])) {
seen.add(match[1])
addresses.push({ address: match[1], type: 'p2sh' })
}
}
for (const match of text.matchAll(TESTNET_REGEX)) {
if (!seen.has(match[1].toLowerCase())) {
seen.add(match[1].toLowerCase())
addresses.push({ address: match[1], type: 'testnet' })
}
}
return addresses
}
export function detectBolt11Invoices(text: string): DetectedLightningInvoice[] {
const invoices: DetectedLightningInvoice[] = []
const seen = new Set<string>()
for (const match of text.matchAll(BOLT11_REGEX)) {
const lower = match[1].toLowerCase()
if (!seen.has(lower)) {
seen.add(lower)
invoices.push({ invoice: match[1] })
}
}
return invoices
}
export function detectBolt12Offers(text: string): DetectedBolt12Offer[] {
const offers: DetectedBolt12Offer[] = []
const seen = new Set<string>()
for (const match of text.matchAll(BOLT12_REGEX)) {
const lower = match[1].toLowerCase()
if (!seen.has(lower)) {
seen.add(lower)
offers.push({ offer: match[1] })
}
}
return offers
}
export function detectTxIds(text: string): DetectedTxId[] {
const txids: DetectedTxId[] = []
const seen = new Set<string>()
for (const match of text.matchAll(TXID_REGEX)) {
if (!seen.has(match[1])) {
seen.add(match[1])
txids.push({ txid: match[1] })
}
}
return txids
}
@@ -0,0 +1,75 @@
import { ref, onMounted, onBeforeUnmount } from 'vue'
const price = ref<number | null>(null)
const currency = ref('USD')
const lastUpdated = ref<number | null>(null)
const isLoading = ref(false)
let refreshTimer: ReturnType<typeof setInterval> | null = null
let initialized = false
const CACHE_TTL = 30_000 // 30 seconds
async function fetchPrice() {
// Skip if we fetched recently (within TTL)
if (lastUpdated.value && Date.now() - lastUpdated.value < CACHE_TTL) return
isLoading.value = true
try {
const res = await fetch('https://mempool.space/api/v1/prices')
if (!res.ok) throw new Error('Failed to fetch price')
const data = await res.json()
price.value = data.USD ?? null
lastUpdated.value = Date.now()
} catch {
// Keep existing price on error
} finally {
isLoading.value = false
}
}
export function useBitcoinPrice() {
function formatPrice(usd: number | null): string {
if (usd === null) return '...'
return '$' + usd.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
}
function satsToUsd(sats: number): string {
if (!price.value) return '...'
const usd = (sats / 100000000) * price.value
if (usd < 0.01) return '<$0.01'
return '$' + usd.toFixed(2)
}
function usdToSats(usd: number): number | null {
if (!price.value) return null
return Math.round((usd / price.value) * 100000000)
}
onMounted(() => {
if (!initialized) {
initialized = true
fetchPrice()
refreshTimer = setInterval(fetchPrice, 60000) // 60s refresh
}
})
onBeforeUnmount(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
initialized = false
}
})
return {
price,
currency,
lastUpdated,
isLoading,
formatPrice,
satsToUsd,
usdToSats,
fetchPrice,
}
}
@@ -0,0 +1,51 @@
import { onUnmounted } from 'vue'
/**
* Memory leak prevention composable.
* Tracks intervals, timeouts, listeners, and observers
* for automatic cleanup on component unmount.
*/
export function useCleanup() {
const intervals: ReturnType<typeof setInterval>[] = []
const timeouts: ReturnType<typeof setTimeout>[] = []
const listeners: { target: EventTarget; event: string; handler: EventListenerOrEventListenerObject }[] = []
const observers: { disconnect: () => void }[] = []
function addInterval(fn: () => void, ms: number): ReturnType<typeof setInterval> {
const id = setInterval(fn, ms)
intervals.push(id)
return id
}
function addTimeout(fn: () => void, ms: number): ReturnType<typeof setTimeout> {
const id = setTimeout(fn, ms)
timeouts.push(id)
return id
}
function addListener(target: EventTarget, event: string, handler: EventListenerOrEventListenerObject, options?: AddEventListenerOptions) {
target.addEventListener(event, handler, options)
listeners.push({ target, event, handler })
}
function addObserver(observer: { disconnect: () => void }) {
observers.push(observer)
return observer
}
onUnmounted(() => {
for (const id of intervals) clearInterval(id)
for (const id of timeouts) clearTimeout(id)
for (const { target, event, handler } of listeners) {
target.removeEventListener(event, handler)
}
for (const obs of observers) obs.disconnect()
})
return {
addInterval,
addTimeout,
addListener,
addObserver,
}
}
@@ -0,0 +1,32 @@
/**
* Extract runnable code blocks (HTML, JS, CSS) from markdown text.
*/
export interface CodeBlock {
language: string
code: string
}
const RUNNABLE_LANGS = new Set(['html', 'javascript', 'js', 'css'])
const FENCED_CODE_RE = /```(html|javascript|js|css)\n([\s\S]*?)```/gi
export function extractRunnableCodeBlocks(text: string): CodeBlock[] {
const results: CodeBlock[] = []
let m: RegExpExecArray | null
const re = new RegExp(FENCED_CODE_RE.source, FENCED_CODE_RE.flags)
while ((m = re.exec(text)) !== null) {
const language = m[1].toLowerCase()
const code = m[2].trim()
if (RUNNABLE_LANGS.has(language) && code.length > 0) {
results.push({ language, code })
}
}
return results
}
export function hasRunnableCode(text: string): boolean {
return FENCED_CODE_RE.test(text)
}
@@ -0,0 +1,285 @@
import { ref, computed, shallowRef, readonly } from 'vue'
import { apiFetch } from '@/utils/api-fetch'
export interface ProjectInfo {
name: string
path: string
isGit: boolean
language?: string
}
export interface FileEntry {
name: string
path: string
isDirectory: boolean
children?: FileEntry[]
}
// Module-level singleton state
const codeMode = ref(false)
const activeProject = ref<ProjectInfo | null>(null)
const projectList = shallowRef<ProjectInfo[]>([])
const fileTree = shallowRef<FileEntry[]>([])
const activeFile = ref<string | null>(null)
const activeFileContent = ref<string>('')
const activeFileLanguage = ref<string>('plaintext')
const selectedDesignTokens = ref<string[]>([])
const selectedFiles = ref<string[]>([])
const fileLoading = ref(false)
const fileError = ref('')
// Demo projects path
const PROJECTS_ROOT = '/Users/dorian/Projects'
function detectLanguage(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
const map: Record<string, string> = {
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
vue: 'vue', svelte: 'svelte', py: 'python', rs: 'rust', go: 'go',
java: 'java', kt: 'kotlin', swift: 'swift', rb: 'ruby', php: 'php',
css: 'css', scss: 'scss', html: 'html', json: 'json', yaml: 'yaml',
yml: 'yaml', md: 'markdown', toml: 'toml', sh: 'shell', bash: 'shell',
sql: 'sql', graphql: 'graphql', dockerfile: 'dockerfile',
c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp',
}
return map[ext] ?? 'plaintext'
}
function detectProjectLanguage(files: string[]): string {
if (files.includes('package.json')) return 'TypeScript/JavaScript'
if (files.includes('Cargo.toml')) return 'Rust'
if (files.includes('go.mod')) return 'Go'
if (files.includes('requirements.txt') || files.includes('setup.py') || files.includes('pyproject.toml')) return 'Python'
if (files.includes('pom.xml') || files.includes('build.gradle')) return 'Java'
if (files.includes('Package.swift')) return 'Swift'
if (files.includes('Gemfile')) return 'Ruby'
if (files.includes('composer.json')) return 'PHP'
if (files.some(f => f.endsWith('.csproj') || f.endsWith('.sln'))) return 'C#'
return 'Unknown'
}
export function useCodeContext() {
const isCodeMode = computed(() => codeMode.value)
const hasActiveProject = computed(() => activeProject.value !== null)
async function loadProjects(): Promise<void> {
// In dev/demo mode, scan the Projects folder
// This would be replaced by Archy integration later
try {
const response = await apiFetch(`/api/fs/list?path=${encodeURIComponent(PROJECTS_ROOT)}`)
if (response.ok) {
const data = await response.json()
projectList.value = data.projects ?? []
}
} catch {
// Fallback: use hardcoded list from build time
// In real app, this would come from local filesystem or Archy nodes
projectList.value = getDemoProjects()
}
}
function getDemoProjects(): ProjectInfo[] {
// Generic demo projects for prod/Archy deployment
return [
{ name: 'my-lightning-app', path: '/projects/my-lightning-app', isGit: true, language: 'TypeScript/JavaScript' },
{ name: 'node-dashboard', path: '/projects/node-dashboard', isGit: true, language: 'TypeScript/JavaScript' },
{ name: 'btc-price-tracker', path: '/projects/btc-price-tracker', isGit: true, language: 'Python' },
{ name: 'nostr-relay-config', path: '/projects/nostr-relay-config', isGit: true, language: 'Rust' },
{ name: 'channel-monitor', path: '/projects/channel-monitor', isGit: true, language: 'Go' },
{ name: 'backup-scripts', path: '/projects/backup-scripts', isGit: false, language: 'Shell' },
]
}
function enterCodeMode(): void {
codeMode.value = true
loadProjects()
}
function exitCodeMode(): void {
codeMode.value = false
activeProject.value = null
activeFile.value = null
activeFileContent.value = ''
fileTree.value = []
selectedDesignTokens.value = []
selectedFiles.value = []
}
function toggleDesignToken(id: string): void {
const idx = selectedDesignTokens.value.indexOf(id)
if (idx >= 0) selectedDesignTokens.value.splice(idx, 1)
else selectedDesignTokens.value.push(id)
}
function isDesignTokenSelected(id: string): boolean {
return selectedDesignTokens.value.includes(id)
}
function clearDesignTokens(): void {
selectedDesignTokens.value = []
}
function toggleFileSelection(path: string): void {
const idx = selectedFiles.value.indexOf(path)
if (idx >= 0) selectedFiles.value.splice(idx, 1)
else selectedFiles.value.push(path)
}
function isFileSelected(path: string): boolean {
return selectedFiles.value.includes(path)
}
function clearFileSelection(): void {
selectedFiles.value = []
}
function selectProject(project: ProjectInfo): void {
activeProject.value = project
loadFileTree(project.path)
}
async function loadFileTree(projectPath: string): Promise<void> {
try {
const response = await apiFetch(`/api/fs/tree?path=${encodeURIComponent(projectPath)}`)
if (response.ok) {
const data = await response.json()
fileTree.value = data.files ?? []
}
} catch {
// Demo fallback: generate a simple tree
fileTree.value = getDemoFileTree()
}
}
function getDemoFileTree(): FileEntry[] {
// Generic project structure for demo
return [
{ name: 'src', path: 'src', isDirectory: true, children: [
{ name: 'index.ts', path: 'src/index.ts', isDirectory: false },
{ name: 'app.ts', path: 'src/app.ts', isDirectory: false },
{ name: 'utils.ts', path: 'src/utils.ts', isDirectory: false },
]},
{ name: 'package.json', path: 'package.json', isDirectory: false },
{ name: 'tsconfig.json', path: 'tsconfig.json', isDirectory: false },
{ name: 'README.md', path: 'README.md', isDirectory: false },
]
}
async function openFile(filePath: string): Promise<void> {
activeFile.value = filePath
activeFileLanguage.value = detectLanguage(filePath)
fileLoading.value = true
fileError.value = ''
try {
const fullPath = activeProject.value
? `${activeProject.value.path}/${filePath}`
: filePath
const response = await apiFetch(`/api/fs/read?path=${encodeURIComponent(fullPath)}`)
if (response.status === 413) {
fileError.value = 'File too large to preview (max 1MB)'
activeFileContent.value = ''
return
}
if (response.ok) {
const data = await response.json()
activeFileContent.value = data.content ?? ''
}
} catch {
// Demo fallback
activeFileContent.value = getDemoFileContent(filePath)
} finally {
fileLoading.value = false
}
}
function getDemoFileContent(filePath: string): string {
const name = filePath.split('/').pop() ?? filePath
if (name === 'package.json') {
return JSON.stringify({
name: activeProject.value?.name?.toLowerCase() ?? 'project',
version: '1.0.0',
type: 'module',
scripts: { dev: 'vite', build: 'vite build', test: 'vitest' },
dependencies: {},
}, null, 2)
}
if (name === 'README.md') {
return `# ${activeProject.value?.name ?? 'Project'}\n\nA project in the AIUI ecosystem.\n`
}
if (name.endsWith('.ts') || name.endsWith('.js')) {
return `// ${name}\n// ${activeProject.value?.name ?? 'Project'}\n\nexport function main() {\n console.log('Hello from ${name}')\n}\n`
}
return `// ${name}\n`
}
async function createProject(name: string): Promise<void> {
const safeName = name.trim().replace(/[^a-zA-Z0-9_\-. ]/g, '')
if (!safeName) return
const projectPath = `${PROJECTS_ROOT}/${safeName}`
try {
const res = await apiFetch('/api/fs/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: projectPath }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
console.warn('[AIUI code] Failed to create directory:', data.error ?? res.status)
}
} catch (err) {
console.warn('[AIUI code] Could not create directory:', err)
}
const newProject: ProjectInfo = {
name: safeName,
path: projectPath,
isGit: false,
language: 'Unknown',
}
projectList.value = [newProject, ...projectList.value]
selectProject(newProject)
}
function clearActiveFile(): void {
activeFile.value = null
activeFileContent.value = ''
activeFileLanguage.value = 'plaintext'
}
return {
// State
codeMode,
isCodeMode,
activeProject,
hasActiveProject,
projectList,
fileTree,
activeFile,
activeFileContent,
activeFileLanguage,
selectedDesignTokens,
selectedFiles,
fileLoading: readonly(fileLoading),
fileError: readonly(fileError),
// Actions
enterCodeMode,
exitCodeMode,
selectProject,
openFile,
loadProjects,
detectLanguage,
createProject,
clearActiveFile,
toggleDesignToken,
isDesignTokenSelected,
clearDesignTokens,
toggleFileSelection,
isFileSelected,
clearFileSelection,
}
}
@@ -0,0 +1,195 @@
import { ref, computed } from 'vue'
import { useNostrIdentity } from './useNostrIdentity'
import { useNostr } from './useNostr'
export interface PlaylistItem {
type: string
title: string
artist?: string
id?: string
addedBy: string
addedAt: number
}
export interface CollaborativePlaylist {
id: string
dTag: string
title: string
description: string
items: PlaylistItem[]
contributors: string[]
createdAt: number
updatedAt: number
}
const STORAGE_KEY = 'aiui-collaborative-playlists'
const playlists = ref<CollaborativePlaylist[]>([])
function loadPlaylists() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) playlists.value = JSON.parse(stored)
} catch { /* ignore */ }
}
function savePlaylists() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(playlists.value))
}
loadPlaylists()
export function useCollaborativePlaylist() {
const { signEvent, pubkey, isLoggedIn } = useNostrIdentity()
const { publishEvent } = useNostr()
const isPublishing = ref(false)
function createPlaylist(title: string, description = ''): CollaborativePlaylist {
const playlist: CollaborativePlaylist = {
id: crypto.randomUUID(),
dTag: `aiui-playlist-${crypto.randomUUID().slice(0, 8)}`,
title,
description,
items: [],
contributors: pubkey.value ? [pubkey.value] : [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
playlists.value.push(playlist)
savePlaylists()
return playlist
}
function addItem(playlistId: string, item: Omit<PlaylistItem, 'addedBy' | 'addedAt'>) {
const playlist = playlists.value.find((p) => p.id === playlistId)
if (!playlist) return
playlist.items.push({
...item,
addedBy: pubkey.value ?? 'local',
addedAt: Date.now(),
})
playlist.updatedAt = Date.now()
savePlaylists()
}
function removeItem(playlistId: string, index: number) {
const playlist = playlists.value.find((p) => p.id === playlistId)
if (!playlist) return
playlist.items.splice(index, 1)
playlist.updatedAt = Date.now()
savePlaylists()
}
function inviteContributor(playlistId: string, npub: string) {
const playlist = playlists.value.find((p) => p.id === playlistId)
if (!playlist) return
if (!playlist.contributors.includes(npub)) {
playlist.contributors.push(npub)
savePlaylists()
}
}
function deletePlaylist(playlistId: string) {
playlists.value = playlists.value.filter((p) => p.id !== playlistId)
savePlaylists()
}
async function publishToNostr(playlistId: string): Promise<boolean> {
if (!isLoggedIn.value || !pubkey.value) return false
const playlist = playlists.value.find((p) => p.id === playlistId)
if (!playlist) return false
isPublishing.value = true
try {
// NIP-51 kind:30004 — Categorized People/Content List
const tags: string[][] = [
['d', playlist.dTag],
['title', playlist.title],
['description', playlist.description],
]
for (const contributor of playlist.contributors) {
tags.push(['p', contributor])
}
for (const item of playlist.items) {
tags.push(['r', JSON.stringify(item)])
}
const unsigned = {
kind: 30004,
pubkey: pubkey.value,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags,
}
const signed = await signEvent(unsigned)
if (!signed) return false
await publishEvent(signed)
return true
} catch {
return false
} finally {
isPublishing.value = false
}
}
function mergeFromNostrEvent(eventContent: string, eventTags: string[][]) {
const dTag = eventTags.find((t) => t[0] === 'd')?.[1]
const titleTag = eventTags.find((t) => t[0] === 'title')?.[1]
if (!dTag) return
let existing = playlists.value.find((p) => p.dTag === dTag)
if (!existing) {
existing = {
id: crypto.randomUUID(),
dTag,
title: titleTag ?? 'Shared Playlist',
description: eventTags.find((t) => t[0] === 'description')?.[1] ?? '',
items: [],
contributors: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
playlists.value.push(existing)
}
// Merge contributors
for (const tag of eventTags) {
if (tag[0] === 'p' && !existing.contributors.includes(tag[1])) {
existing.contributors.push(tag[1])
}
}
// Merge items
for (const tag of eventTags) {
if (tag[0] === 'r') {
try {
const item = JSON.parse(tag[1]) as PlaylistItem
const exists = existing.items.some(
(i) => i.title === item.title && i.type === item.type
)
if (!exists) existing.items.push(item)
} catch { /* skip invalid */ }
}
}
existing.updatedAt = Date.now()
savePlaylists()
}
return {
playlists: computed(() => playlists.value),
isPublishing,
createPlaylist,
addItem,
removeItem,
inviteContributor,
deletePlaylist,
publishToNostr,
mergeFromNostrEvent,
}
}
@@ -0,0 +1,101 @@
import { ref, computed } from 'vue'
const comparisonEnabled = ref(false)
const model1 = ref({ provider: 'claude' as string, model: 'claude-haiku-4.5' })
const model2 = ref({ provider: 'openrouter' as string, model: 'meta-llama/llama-4-maverick' })
const response1 = ref('')
const response2 = ref('')
const isStreaming1 = ref(false)
const isStreaming2 = ref(false)
const error1 = ref<string | null>(null)
const error2 = ref<string | null>(null)
let abortController: AbortController | null = null
export function useComparisonMode() {
const isComparing = computed(() => comparisonEnabled.value)
const isAnyStreaming = computed(() => isStreaming1.value || isStreaming2.value)
function toggleComparison() {
comparisonEnabled.value = !comparisonEnabled.value
}
function setModel(slot: 1 | 2, provider: string, modelId: string) {
const target = slot === 1 ? model1 : model2
target.value = { provider, model: modelId }
}
function clearResponses() {
response1.value = ''
response2.value = ''
error1.value = null
error2.value = null
}
function stopComparison() {
if (abortController) {
abortController.abort()
abortController = null
}
isStreaming1.value = false
isStreaming2.value = false
}
async function streamBothModels(
streamFn: (
provider: string,
model: string,
messages: { role: string; content: string }[],
onToken: (text: string) => void,
onError: (err: string) => void,
signal: AbortSignal,
) => Promise<void>,
messages: { role: string; content: string }[],
) {
clearResponses()
abortController = new AbortController()
const signal = abortController.signal
isStreaming1.value = true
isStreaming2.value = true
const p1 = streamFn(
model1.value.provider,
model1.value.model,
messages,
(token) => { response1.value += token },
(err) => { error1.value = err },
signal,
).finally(() => { isStreaming1.value = false })
const p2 = streamFn(
model2.value.provider,
model2.value.model,
messages,
(token) => { response2.value += token },
(err) => { error2.value = err },
signal,
).finally(() => { isStreaming2.value = false })
await Promise.allSettled([p1, p2])
abortController = null
}
return {
comparisonEnabled,
isComparing,
isAnyStreaming,
model1,
model2,
response1,
response2,
isStreaming1,
isStreaming2,
error1,
error2,
toggleComparison,
setModel,
clearResponses,
stopComparison,
streamBothModels,
}
}
@@ -0,0 +1,89 @@
import { ref } from 'vue'
import type { FavoriteType } from '@/stores/favorites'
const STORAGE_KEY = 'aiui-content-collections'
export interface CollectionItem {
id: string
type: FavoriteType
title: string
}
export interface ContentCollection {
id: string
name: string
description: string
items: CollectionItem[]
createdAt: number
updatedAt: number
}
const collections = ref<ContentCollection[]>([])
function load() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) collections.value = JSON.parse(stored)
} catch { /* ignore */ }
}
function save() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(collections.value))
}
load()
export function useContentCollections() {
function createCollection(name: string, description = ''): ContentCollection {
const collection: ContentCollection = {
id: crypto.randomUUID(),
name,
description,
items: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
collections.value.push(collection)
save()
return collection
}
function deleteCollection(id: string) {
collections.value = collections.value.filter(c => c.id !== id)
save()
}
function addToCollection(collectionId: string, item: CollectionItem) {
const collection = collections.value.find(c => c.id === collectionId)
if (!collection) return
if (collection.items.find(i => i.id === item.id)) return
collection.items.push(item)
collection.updatedAt = Date.now()
save()
}
function removeFromCollection(collectionId: string, itemId: string) {
const collection = collections.value.find(c => c.id === collectionId)
if (!collection) return
collection.items = collection.items.filter(i => i.id !== itemId)
collection.updatedAt = Date.now()
save()
}
function renameCollection(id: string, name: string) {
const collection = collections.value.find(c => c.id === id)
if (!collection) return
collection.name = name
collection.updatedAt = Date.now()
save()
}
return {
collections,
createCollection,
deleteCollection,
addToCollection,
removeFromCollection,
renameCollection,
}
}
@@ -0,0 +1,231 @@
import { ref, computed } from 'vue'
import { useFavoritesStore, type FavoriteItem, type FavoriteType } from '@/stores/favorites'
// M13.1 — "For You" feed
export function useForYouFeed() {
const favoritesStore = useFavoritesStore()
const forYouItems = computed(() => {
const items = favoritesStore.sortedItems
if (items.length === 0) return []
// Build frequency map by type
const typeFreq = new Map<FavoriteType, number>()
for (const item of items) {
typeFreq.set(item.type, (typeFreq.get(item.type) ?? 0) + 1)
}
// Sort types by frequency (most favorited first)
const sortedTypes = [...typeFreq.entries()]
.sort((a, b) => b[1] - a[1])
.map(([type]) => type)
// Return items prioritized by favorite frequency, most recent first
return [...items].sort((a, b) => {
const aRank = sortedTypes.indexOf(a.type)
const bRank = sortedTypes.indexOf(b.type)
if (aRank !== bRank) return aRank - bRank
return b.savedAt - a.savedAt
})
})
return { forYouItems }
}
// M13.2 — Content tagging
const TAGS_STORAGE_KEY = 'aiui-content-tags'
export interface ContentTag {
itemId: string
tags: string[]
}
const contentTags = ref<Map<string, string[]>>(new Map())
function loadTags() {
try {
const stored = localStorage.getItem(TAGS_STORAGE_KEY)
if (stored) {
const entries = JSON.parse(stored) as [string, string[]][]
contentTags.value = new Map(entries)
}
} catch { /* ignore */ }
}
function saveTags() {
localStorage.setItem(TAGS_STORAGE_KEY, JSON.stringify([...contentTags.value.entries()]))
}
loadTags()
export function useContentTags() {
function getTagsForItem(itemId: string): string[] {
return contentTags.value.get(itemId) ?? []
}
function addTag(itemId: string, tag: string) {
const tags = getTagsForItem(itemId)
if (!tags.includes(tag)) {
contentTags.value.set(itemId, [...tags, tag])
saveTags()
}
}
function removeTag(itemId: string, tag: string) {
const tags = getTagsForItem(itemId).filter(t => t !== tag)
if (tags.length === 0) {
contentTags.value.delete(itemId)
} else {
contentTags.value.set(itemId, tags)
}
saveTags()
}
const allTags = computed(() => {
const tagSet = new Set<string>()
for (const tags of contentTags.value.values()) {
for (const tag of tags) tagSet.add(tag)
}
return [...tagSet].sort()
})
const tagCloud = computed(() => {
const counts = new Map<string, number>()
for (const tags of contentTags.value.values()) {
for (const tag of tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1)
}
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.map(([tag, count]) => ({ tag, count }))
})
function getItemsByTag(tag: string): string[] {
const ids: string[] = []
for (const [id, tags] of contentTags.value.entries()) {
if (tags.includes(tag)) ids.push(id)
}
return ids
}
return {
contentTags,
allTags,
tagCloud,
getTagsForItem,
addTag,
removeTag,
getItemsByTag,
}
}
// M13.5 — Recently viewed history
const HISTORY_STORAGE_KEY = 'aiui-view-history'
const MAX_HISTORY = 50
export interface HistoryEntry {
id: string
type: FavoriteType
title: string
subtitle?: string
viewedAt: number
}
const viewHistory = ref<HistoryEntry[]>([])
function loadHistory() {
try {
const stored = localStorage.getItem(HISTORY_STORAGE_KEY)
if (stored) viewHistory.value = JSON.parse(stored)
} catch { /* ignore */ }
}
function saveHistory() {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(viewHistory.value))
}
loadHistory()
export function useViewHistory() {
function addToHistory(entry: Omit<HistoryEntry, 'viewedAt'>) {
// Remove duplicate
viewHistory.value = viewHistory.value.filter(h => h.id !== entry.id)
// Add to front
viewHistory.value.unshift({ ...entry, viewedAt: Date.now() })
// Cap at MAX_HISTORY
if (viewHistory.value.length > MAX_HISTORY) {
viewHistory.value = viewHistory.value.slice(0, MAX_HISTORY)
}
saveHistory()
}
function clearHistory() {
viewHistory.value = []
saveHistory()
}
return {
viewHistory,
addToHistory,
clearHistory,
}
}
// M13.7 — Trending in conversations
const REFERENCE_STORAGE_KEY = 'aiui-content-references'
interface ReferenceEntry {
id: string
title: string
type: FavoriteType
count: number
lastReferenced: number
}
const trendingItems = ref<ReferenceEntry[]>([])
function loadReferences() {
try {
const stored = localStorage.getItem(REFERENCE_STORAGE_KEY)
if (stored) trendingItems.value = JSON.parse(stored)
} catch { /* ignore */ }
}
function saveReferences() {
localStorage.setItem(REFERENCE_STORAGE_KEY, JSON.stringify(trendingItems.value))
}
loadReferences()
export function useTrending() {
function recordReference(item: { id: string; title: string; type: FavoriteType }) {
const existing = trendingItems.value.find(t => t.id === item.id)
if (existing) {
existing.count++
existing.lastReferenced = Date.now()
} else {
trendingItems.value.push({
id: item.id,
title: item.title,
type: item.type,
count: 1,
lastReferenced: Date.now(),
})
}
saveReferences()
}
const trending = computed(() => {
const thirtyDaysAgo = Date.now() - 30 * 86400 * 1000
return trendingItems.value
.filter(t => t.lastReferenced > thirtyDaysAgo)
.sort((a, b) => b.count - a.count)
.slice(0, 20)
})
return {
trending,
recordReference,
}
}
@@ -0,0 +1,59 @@
import { ref, reactive, watch, isRef, type Ref } from 'vue'
interface UseContentImagesOptions<T> {
items: Ref<T[]> | (() => T[])
id: (item: T) => string
existingUrl: (item: T) => string | undefined | null
fetch: (item: T) => Promise<string | null>
fallback: (item: T) => string
}
export function useContentImages<T>(options: UseContentImagesOptions<T>) {
const failed = ref<Set<string>>(new Set())
const fetched = reactive<Map<string, string>>(new Map())
function markFailed(id: string) {
failed.value = new Set([...failed.value, id])
}
function coverSrc(item: T): string | null {
const itemId = options.id(item)
if (failed.value.has(itemId)) return null
return options.existingUrl(item) || fetched.get(itemId) || null
}
function fallbackSrc(item: T): string {
return options.fallback(item)
}
function onError(item: T) {
markFailed(options.id(item))
}
function isLoading(item: T): boolean {
const itemId = options.id(item)
return !coverSrc(item) && !failed.value.has(itemId)
}
function fetchCovers(list: T[]) {
for (const item of list) {
const itemId = options.id(item)
if (options.existingUrl(item) || fetched.has(itemId) || failed.value.has(itemId)) continue
options.fetch(item).then((url) => {
if (url) {
fetched.set(itemId, url)
} else {
markFailed(itemId)
}
}).catch(() => {
markFailed(itemId)
})
}
}
const src = options.items
const itemsGetter: () => T[] = isRef(src) ? () => src.value : src
watch(itemsGetter, (list) => fetchCovers(list), { immediate: true })
return { coverSrc, fallbackSrc, onError, isLoading }
}
@@ -0,0 +1,179 @@
import { ref, computed } from 'vue'
export interface ContentPackItem {
type: string
title: string
data: Record<string, unknown>
}
export interface ContentPack {
id: string
name: string
description: string
version: string
author: string
items: ContentPackItem[]
installedAt?: number
source?: string
}
const STORAGE_KEY = 'aiui-content-packs'
const installedPacks = ref<ContentPack[]>([])
function loadPacks() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) installedPacks.value = JSON.parse(stored)
} catch { /* ignore */ }
}
function savePacks() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(installedPacks.value))
}
loadPacks()
// Built-in registry of available packs
const registryPacks: Omit<ContentPack, 'installedAt'>[] = [
{
id: 'pack-best-films-2024',
name: '2024 Best Films',
description: 'Curated selection of the best films released in 2024.',
version: '1.0.0',
author: 'AIUI Community',
source: 'builtin',
items: [
{ type: 'film', title: 'Dune: Part Two', data: { year: 2024, director: 'Denis Villeneuve', rating: 8.3 } },
{ type: 'film', title: 'The Substance', data: { year: 2024, director: 'Coralie Fargeat', rating: 7.5 } },
{ type: 'film', title: 'Conclave', data: { year: 2024, director: 'Edward Berger', rating: 7.8 } },
{ type: 'film', title: 'Anora', data: { year: 2024, director: 'Sean Baker', rating: 7.9 } },
{ type: 'film', title: 'The Brutalist', data: { year: 2024, director: 'Brady Corbet', rating: 7.6 } },
],
},
{
id: 'pack-bitcoin-music',
name: 'Bitcoin Music Playlist',
description: 'Songs celebrating Bitcoin, sound money, and sovereignty.',
version: '1.0.0',
author: 'AIUI Community',
source: 'builtin',
items: [
{ type: 'song', title: 'Value 4 Value', data: { artist: 'Ainsley Costello', year: 2023 } },
{ type: 'song', title: 'Bitcoin Thunder', data: { artist: 'Mandrik', year: 2022 } },
{ type: 'song', title: 'Bitcoin is Dead', data: { artist: 'HODL Band', year: 2024 } },
{ type: 'song', title: 'Sound Money', data: { artist: 'Cory Klippsten', year: 2023 } },
{ type: 'song', title: 'Stack Sats', data: { artist: 'Pleb Music', year: 2024 } },
],
},
{
id: 'pack-essential-nostr',
name: 'Essential Nostr Reads',
description: 'Must-read resources for understanding and building on Nostr.',
version: '1.0.0',
author: 'AIUI Community',
source: 'builtin',
items: [
{ type: 'book', title: 'The Nostr Protocol', data: { author: 'fiatjaf', year: 2023, category: 'Protocol Spec' } },
{ type: 'book', title: 'NIP-01: Basic Protocol', data: { author: 'fiatjaf', year: 2022, category: 'NIP' } },
{ type: 'book', title: 'Decentralized Social Media', data: { author: 'Various', year: 2023, category: 'Guide' } },
{ type: 'book', title: 'Building Censorship-Resistant Apps', data: { author: 'Nostr Community', year: 2024, category: 'Tutorial' } },
],
},
]
export function useContentPacks() {
const availablePacks = computed(() => {
const installedIds = new Set(installedPacks.value.map((p) => p.id))
return registryPacks.filter((p) => !installedIds.has(p.id))
})
function installPack(packId: string): boolean {
const pack = registryPacks.find((p) => p.id === packId)
if (!pack) return false
const installed: ContentPack = {
...pack,
installedAt: Date.now(),
}
installedPacks.value.push(installed)
savePacks()
return true
}
function uninstallPack(packId: string) {
installedPacks.value = installedPacks.value.filter((p) => p.id !== packId)
savePacks()
}
async function importFromUrl(url: string): Promise<ContentPack | null> {
try {
const parsed = new URL(url)
if (parsed.protocol !== 'https:') return null
const response = await fetch(url)
if (!response.ok) return null
const data = (await response.json()) as ContentPack
if (
!data.id || typeof data.id !== 'string' ||
!data.name || typeof data.name !== 'string' ||
!Array.isArray(data.items) ||
!data.items.every((item: unknown) => {
const i = item as Record<string, unknown>
return i && typeof i.type === 'string' && typeof i.title === 'string'
})
) return null
const existing = installedPacks.value.find((p) => p.id === data.id)
if (existing) {
Object.assign(existing, data, { installedAt: Date.now(), source: url })
} else {
installedPacks.value.push({ ...data, installedAt: Date.now(), source: url })
}
savePacks()
return data
} catch {
return null
}
}
function importFromJson(json: string): ContentPack | null {
try {
const data = JSON.parse(json) as ContentPack
if (!data.id || !data.name || !data.items) return null
const existing = installedPacks.value.find((p) => p.id === data.id)
if (existing) {
Object.assign(existing, data, { installedAt: Date.now() })
} else {
installedPacks.value.push({ ...data, installedAt: Date.now() })
}
savePacks()
return data
} catch {
return null
}
}
function getPackItems(packId: string): ContentPackItem[] {
const pack = installedPacks.value.find((p) => p.id === packId)
return pack?.items ?? []
}
function getAllInstalledItems(type?: string): ContentPackItem[] {
const all = installedPacks.value.flatMap((p) => p.items)
return type ? all.filter((i) => i.type === type) : all
}
return {
installedPacks: computed(() => installedPacks.value),
availablePacks,
registryPacks,
installPack,
uninstallPack,
importFromUrl,
importFromJson,
getPackItems,
getAllInstalledItems,
}
}
@@ -0,0 +1,729 @@
import { ref } from 'vue'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import type { RecipeData } from './contentExtraction'
import type { WebSearchResult } from '@aiui/core/types/message'
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
import { mockPodcasts } from '@/mocks/podcasts'
// Demo-site content pack (operator decision 2026-08-07): the library views
// fill from mocks in demo/dev builds only; production folds these to empty.
const demoFilms: Film[] = (import.meta.env.DEV || import.meta.env.VITE_DEMO_CONTENT === 'true') ? mockFilms : []
const demoSongs: Song[] = (import.meta.env.DEV || import.meta.env.VITE_DEMO_CONTENT === 'true') ? mockSongs : []
const demoPodcasts: Podcast[] = (import.meta.env.DEV || import.meta.env.VITE_DEMO_CONTENT === 'true') ? mockPodcasts : []
import { fetchRssFromUrls } from '@/composables/useRssFetch'
import {
extractAllFilms, extractAllSongs, extractAllPodcasts, extractAllBooks,
extractAllTVSeries, extractAllImages, extractAllPlaces,
extractMagazineSections, extractMagazineHeroImage,
extractMarkdownLinks, extractBoldDomainLinks, extractBareDomainLinks, mergeNewsResults,
extractFilmIds, extractSongIds, extractPodcastIds,
extractApps, extractCodeBlocks, extractRecipes,
stripFilmTags, stripSongTags, stripPodcastTags, stripContentTags, stripMarkdownLinks,
} from './contentExtraction'
import type { AppEntry, CodeBlock } from './contentExtraction'
import {
isNewsQuery, isNewsLikeResponse, isTVQuery,
isNostrQuery, isNostrLikeResponse,
isCodeQuery, isCodeLikeResponse,
isRecipeLikeResponse,
filterTabsByContext, extractQueryContext,
} from './contentFiltering'
export type { ContentTab, MagazineSection } from './contentFiltering'
import type { ContentTab, MagazineSection } from './contentFiltering'
const panelOpen = ref(false)
const panelFilms = ref<Film[]>([])
const panelBooks = ref<Book[]>([])
const panelTVSeries = ref<TVSeries[]>([])
const panelWebResults = ref<WebSearchResult[]>([])
const panelRssArticles = ref<WebSearchResult[]>([])
const panelWebsites = ref<WebSearchResult[]>([])
const panelMagazineSections = ref<MagazineSection[]>([])
const panelMagazineHeroImage = ref<string | null>(null)
const panelSongs = ref<Song[]>([])
const panelPodcasts = ref<Podcast[]>([])
const panelImages = ref<ImageItem[]>([])
const panelPlaces = ref<Place[]>([])
const panelRecipes = ref<RecipeData[]>([])
const panelApps = ref<AppEntry[]>([])
const panelCodeBlocks = ref<CodeBlock[]>([])
const selectedFilm = ref<Film | null>(null)
const selectedBook = ref<Book | null>(null)
const selectedTVSeries = ref<TVSeries | null>(null)
const selectedSong = ref<Song | null>(null)
const selectedPodcast = ref<Podcast | null>(null)
const selectedArticle = ref<WebSearchResult | null>(null)
const selectedImage = ref<ImageItem | null>(null)
const selectedPlace = ref<Place | null>(null)
const selectedWebsite = ref<WebSearchResult | null>(null)
const selectedRecipe = ref<RecipeData | null>(null)
const selectedApp = ref<AppEntry | null>(null)
const selectedMagazineSection = ref<MagazineSection | null>(null)
const magazineSectionIndex = ref(0)
const panelTitle = ref('Recommended Films')
const panelQuery = ref('')
const panelResponseText = ref('')
const contentType = ref<'film' | 'song' | 'podcast'>('film')
const activeTab = ref<ContentTab>('film')
const availableTabs = ref<ContentTab[]>([])
const selectedDesignSystemItem = ref<DesignSystemItem | null>(null)
const longFormArticle = ref<{ content: string; title?: string } | null>(null)
const pdfUrl = ref<{ url: string; title?: string } | null>(null)
const mapPlaces = ref<Place[]>([])
/** True once `setArchyContent` has populated panelFilms/panelSongs/
* panelPodcasts from a real node (D-12). While true, `updatePanelFromText`
* leaves those three buckets alone — the Archy-sourced grids are the
* source of truth for them, not the model's own reply text. Every other
* bucket (books, TV, images, places, magazine, code, recipes, news) has no
* Archy source in this plan's scope and keeps using the regex path
* unconditionally (13-PATTERNS.md: partial deprecation, not a removal). */
const archyContentActive = ref(false)
/** Which buckets the node's LATEST archy delivery actually filled. The
* regex-extraction path must never overwrite a bucket the node supplied
* (D-12 node truth), but a bucket the node left EMPTY stays writable so a
* recommendation reply's [[film_ext:…]] previews still render as cards —
* the global latch used to suppress exactly those (the "chat lost its rich
* previews" regression: once mount-time content latched the flag, no
* extracted card could ever render again). */
const archySupplied = ref({ film: false, song: false, podcast: false, image: false })
/** A chat turn that may produce content is in flight. Until it resolves,
* the panel heading must NOT keep advertising the previous query's
* results — the operator reads that as the answer to what they just
* asked. `beginArchyContentLoad` raises this and `setArchyContent`
* lowers it. */
const archyContentLoading = ref(false)
export interface DesignSystemItem {
id: string
name: string
category: 'colors' | 'typography' | 'spacing' | 'atoms' | 'molecules' | 'organisms'
description: string
code: string
preview?: 'inline'
usedIn?: string
}
export function useContentPanel() {
function updatePanelFromText(text: string, userQuery = '', webResults: WebSearchResult[] = []) {
panelQuery.value = userQuery.trim()
panelResponseText.value = text
const songs = extractAllSongs(text, userQuery)
let films = extractAllFilms(text)
const podcasts = extractAllPodcasts(text)
const books = extractAllBooks(text, userQuery)
const tvSeries = extractAllTVSeries(text, userQuery)
if (tvSeries.length > 0 && isTVQuery(userQuery)) {
films = films.filter(f => !f.id.startsWith('ext-'))
}
const fromMarkdown = extractMarkdownLinks(text)
const boldDomains = extractBoldDomainLinks(text)
const bareDomains = extractBareDomainLinks(text)
panelRssArticles.value = []
const hasLinkableContent = fromMarkdown.length > 0 || boldDomains.length > 0 || bareDomains.length > 0
const websitesFromMarkdown = hasLinkableContent ? fromMarkdown : []
const mergedWebsites = mergeNewsResults(mergeNewsResults(websitesFromMarkdown, boldDomains), bareDomains)
const hasWebsites = mergedWebsites.length > 0
// App extraction
const apps = extractApps(text, userQuery)
const hasApps = apps.length > 0
// Code block extraction
const codeBlocks = extractCodeBlocks(text)
const hasCode = codeBlocks.length > 0 && (isCodeQuery(userQuery) || isCodeLikeResponse(text))
// Nostr detection
const hasNostr = isNostrQuery(userQuery) || isNostrLikeResponse(text)
const images = extractAllImages(text, userQuery)
const places = extractAllPlaces(text, userQuery)
const recipes = extractRecipes(text)
const hasRecipes = recipes.length > 0 || isRecipeLikeResponse(text)
// Magazine = bullet-style sections (- **Title**: Content)
const magazineSections = extractMagazineSections(text)
const hasAnyOtherContent = films.length > 0 || songs.length > 0 || podcasts.length > 0 ||
books.length > 0 || tvSeries.length > 0 || images.length > 0 || places.length > 0
const hasMagazine = magazineSections.length >= 1 && (
isNewsQuery(userQuery) || isNewsLikeResponse(text) ||
/sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening|ETF|inflow|trading at|key developments|price recovery|institutional|analyst watch|market cap/i.test(text) ||
(!hasAnyOtherContent && !hasWebsites && magazineSections.length >= 2)
)
// News = actual articles (web search + RSS from website domains)
const newsContext = isNewsQuery(userQuery) || isNewsLikeResponse(text)
// Show news tab eagerly when query is news-like — results may arrive async
const hasNews = newsContext && (webResults.length > 0 || mergedWebsites.length > 0 || isNewsQuery(userQuery))
const mergedNews = mergeNewsResults(webResults, panelRssArticles.value)
if (mergedWebsites.length > 0 && newsContext) {
const urls = mergedWebsites.map((w) => w.url)
fetchRssFromUrls(urls).then((articles) => {
if (articles.length === 0) return
panelRssArticles.value = articles
const combined = mergeNewsResults(panelWebResults.value, articles)
panelWebResults.value = combined
if (!availableTabs.value.includes('news')) {
// Insert before 'prompt' (which is always last)
const promptIdx = availableTabs.value.indexOf('prompt')
if (promptIdx >= 0) {
availableTabs.value = [...availableTabs.value.slice(0, promptIdx), 'news', ...availableTabs.value.slice(promptIdx)]
} else {
availableTabs.value = ['news', ...availableTabs.value]
}
activeTab.value = 'news'
}
const ctx = extractQueryContext(panelQuery.value)
panelTitle.value = ctx ? `${ctx}${combined.length} articles` : `${combined.length} Articles`
})
}
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine, hasNostr, hasApps, hasCode, hasRecipes)
// Real node content outranks anything inferred from the reply text.
//
// `setArchyContent` runs first and puts the node's films/songs/podcasts/
// images on the tab bar; this function then ran and REPLACED the bar with
// its own regex-derived tabs. Asking "show me my own shared content"
// therefore ended on an "AI Brief" — a prose restatement — while the
// populated image grid was no longer reachable. Guarding the panel*
// arrays (above) was not enough: the arrays held the right data and the
// tab bar had thrown away the way to see it.
// Ordered by how much the node actually returned, not by a fixed type
// preference: "show me my own shared content" on a node with 13 photos
// and 2 tracks opened on Songs, so the answer's own subject was one
// click away and the heading read "2 Songs" for a 15-item reply.
const archyTabs: ContentTab[] = archyContentActive.value
? ([
['film', panelFilms.value.length],
['song', panelSongs.value.length],
['podcast', panelPodcasts.value.length],
['image', panelImages.value.length],
] as [ContentTab, number][])
.filter(([, n]) => n > 0)
.sort((a, b) => b[1] - a[1])
.map(([t]) => t)
: []
const inferredTabs = tabs.filter((t) => !archyTabs.includes(t))
const orderedTabs = [...archyTabs, ...inferredTabs]
// Always append Prompt tab as the rightmost tab
const tabsWithPrompt = orderedTabs.length > 0 ? [...orderedTabs, 'prompt' as const] : ['prompt' as const]
availableTabs.value = tabsWithPrompt
activeTab.value = orderedTabs[0] ?? 'prompt'
const showFilms = tabs.includes('film')
const showBooks = tabs.includes('book')
const showTVSeries = tabs.includes('tvshow')
const showImages = tabs.includes('image')
const showPlaces = tabs.includes('place')
const showSongs = tabs.includes('song')
const showPodcasts = tabs.includes('podcast')
const showNews = tabs.includes('news')
const showWebsitesTab = tabs.includes('websites')
const showMagazine = tabs.includes('magazine')
const showRecipes = tabs.includes('recipe')
const showApps = tabs.includes('app')
const showCode = tabs.includes('code')
const visibleFilms = showFilms ? films : []
const visibleBooks = showBooks ? books : []
const visibleTVSeries = showTVSeries ? tvSeries : []
const visibleImages = showImages ? images : []
const visiblePlaces = showPlaces ? places : []
const visibleSongs = showSongs ? songs : []
const visiblePodcasts = showPodcasts ? podcasts : []
const visibleNews = showNews ? mergedNews : []
const visibleWebsites = showWebsitesTab ? mergedWebsites : []
const visibleMagazineSections = showMagazine ? magazineSections : []
const visibleRecipes = showRecipes ? recipes : []
const visibleApps = showApps ? apps : []
const visibleCodeBlocks = showCode ? codeBlocks : []
// Archy-sourced films/songs/podcasts/images are the source of truth
// once a node has supplied them (D-12) — don't let this turn's regex
// extraction of the model's own reply text overwrite them. Images
// joined this set when the adapter started carrying shared photos;
// leaving them out here would have let the regex path immediately
// wipe the grid the node had just filled.
// Per-bucket, not global: the node's truth wins a bucket it filled;
// an empty bucket stays open to this turn's extracted previews.
if (!archySupplied.value.film) panelFilms.value = visibleFilms
if (!archySupplied.value.song) panelSongs.value = visibleSongs
if (!archySupplied.value.podcast) panelPodcasts.value = visiblePodcasts
if (!archySupplied.value.image) panelImages.value = visibleImages
panelBooks.value = visibleBooks
panelTVSeries.value = visibleTVSeries
panelPlaces.value = visiblePlaces
panelWebResults.value = visibleNews
panelWebsites.value = visibleWebsites
panelRecipes.value = visibleRecipes
panelApps.value = visibleApps
panelCodeBlocks.value = visibleCodeBlocks
panelMagazineSections.value = visibleMagazineSections
panelMagazineHeroImage.value = showMagazine
? (extractMagazineHeroImage(text) ?? webResults[0]?.imgSrc ?? null)
: null
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
selectedRecipe.value = null
selectedApp.value = null
selectedWebsite.value = null
selectedMagazineSection.value = null
if (visibleFilms.length > 0) contentType.value = 'film'
else if (visibleBooks.length > 0) contentType.value = 'film'
else if (visibleTVSeries.length > 0) contentType.value = 'film'
else if (visibleSongs.length > 0) contentType.value = 'song'
else if (visiblePodcasts.length > 0) contentType.value = 'podcast'
else if (visibleNews.length > 0) contentType.value = 'film'
else contentType.value = 'film'
// Title follows the primary (first) tab — which, when the node supplied
// content, is an Archy tab. Titling from `tabs[0]` (the regex path's
// own first tab) would name a grid that is no longer the one on screen.
const primary = orderedTabs[0]
if (archyTabs.includes(primary as ContentTab)) {
if (primary === 'image') panelTitle.value = `${panelImages.value.length} Images`
else if (primary === 'film') {
panelTitle.value = panelFilms.value.length === 1
? panelFilms.value[0]!.title
: `${panelFilms.value.length} Films`
} else if (primary === 'song') {
panelTitle.value = panelSongs.value.length === 1
? panelSongs.value[0]!.title
: `${panelSongs.value.length} Songs`
} else {
panelTitle.value = panelPodcasts.value.length === 1
? panelPodcasts.value[0]!.title
: `${panelPodcasts.value.length} Podcasts`
}
} else if (primary === 'place' && visiblePlaces.length > 0) {
panelTitle.value = visiblePlaces.length === 1 ? visiblePlaces[0].name : `${visiblePlaces.length} Places`
} else if (primary === 'tvshow' && visibleTVSeries.length > 0) {
panelTitle.value = visibleTVSeries.length === 1 ? visibleTVSeries[0].title : `${visibleTVSeries.length} TV Series`
} else if (primary === 'book' && visibleBooks.length > 0) {
panelTitle.value = visibleBooks.length === 1 ? visibleBooks[0].title : `${visibleBooks.length} Books`
} else if (primary === 'image' && visibleImages.length > 0) {
panelTitle.value = `${visibleImages.length} Images`
} else if (visibleFilms.length === 1) panelTitle.value = visibleFilms[0].title
else if (visibleFilms.length > 1) panelTitle.value = `${visibleFilms.length} Films`
else if (visibleBooks.length === 1) panelTitle.value = visibleBooks[0].title
else if (visibleBooks.length > 1) panelTitle.value = `${visibleBooks.length} Books`
else if (visibleTVSeries.length === 1) panelTitle.value = visibleTVSeries[0].title
else if (visibleTVSeries.length > 1) panelTitle.value = `${visibleTVSeries.length} TV Series`
else if (visibleSongs.length === 1) panelTitle.value = visibleSongs[0].title
else if (visibleSongs.length > 1) panelTitle.value = `${visibleSongs.length} Songs`
else if (visiblePlaces.length === 1) panelTitle.value = visiblePlaces[0].name
else if (visiblePlaces.length > 1) panelTitle.value = `${visiblePlaces.length} Places`
else if (visiblePodcasts.length === 1) panelTitle.value = visiblePodcasts[0].title
else if (visiblePodcasts.length > 1) panelTitle.value = `${visiblePodcasts.length} Podcasts`
else if (visibleNews.length > 0) {
const ctx = extractQueryContext(userQuery)
panelTitle.value = ctx ? `${ctx}${visibleNews.length} articles` : `${visibleNews.length} Articles`
}
else if (visibleMagazineSections.length > 0) {
const ctx = extractQueryContext(userQuery)
panelTitle.value = ctx ? `${ctx} — Brief` : 'AI Brief'
}
else if (visibleRecipes.length === 1) panelTitle.value = visibleRecipes[0].title
else if (visibleRecipes.length > 1) panelTitle.value = `${visibleRecipes.length} Recipes`
else if (visibleCodeBlocks.length > 0) panelTitle.value = `${visibleCodeBlocks.length} Code Blocks`
else if (visibleApps.length > 0) panelTitle.value = `${visibleApps.length} Apps`
else if (visibleWebsites.length > 0) panelTitle.value = `${visibleWebsites.length} Websites`
else if (hasNostr) panelTitle.value = 'Nostr'
// Zero extraction tabs: leave the title alone when an archy delivery
// just said 'Nothing found' (or is still 'Loading…') — the closed
// panel's heading must not silently become 'Content'.
else if (panelTitle.value !== 'Nothing found' && panelTitle.value !== 'Loading…') panelTitle.value = 'Content'
// Open from the ORDERED tab list (Archy tabs + inferred), not the
// regex-only `tabs`: a node-supplied grid whose reply text happens to
// match no extraction pattern (a plain markdown list of purchased
// files) used to CLOSE the panel setArchyContent had just opened —
// the probe showed surfaces=1 images=3 arriving and the user saw prose.
panelOpen.value = orderedTabs.length > 0
}
/**
* Populate the film/song/podcast grids directly from a node's real
* content (D-12) — `useArchy.ts`'s `requestArchyContent` calls this from
* its `content:push` handler. Bypasses `updatePanelFromText`'s regex
* path entirely for these three buckets and marks `archyContentActive`
* so a later `updatePanelFromText` call (from an unrelated chat turn)
* does not clobber them back to a regex-scraped or empty state.
*
* TMDB posters, web search and RSS remain unavailable on a node (their
* Vite plugins are dev-server-only — 13-CONTEXT.md landmines), so a
* `Film`/`Song` adapted from peer/own-node data has no `posterUrl`/
* `coverUrl`; `FilmGrid`/`SongGrid` already render their existing
* no-artwork fallback for that case (unchanged by this plan, D-12).
*/
/** A content-capable chat turn just started. Clears the stale heading
* so the panel says what it is doing rather than what it last found. */
function beginArchyContentLoad() {
archyContentLoading.value = true
panelTitle.value = 'Loading…'
}
function setArchyContent(bundle: {
films?: Film[]
songs?: Song[]
podcasts?: Podcast[]
images?: ImageItem[]
}) {
panelFilms.value = bundle.films ?? []
panelSongs.value = bundle.songs ?? []
panelPodcasts.value = bundle.podcasts ?? []
panelImages.value = bundle.images ?? []
archySupplied.value = {
film: panelFilms.value.length > 0,
song: panelSongs.value.length > 0,
podcast: panelPodcasts.value.length > 0,
image: panelImages.value.length > 0,
}
// Latch only on a non-empty delivery: an empty tool result must not
// suppress the recommendation previews extraction can still produce.
archyContentActive.value =
archySupplied.value.film || archySupplied.value.song ||
archySupplied.value.podcast || archySupplied.value.image
// 13-11 (GAP-FOUND 2026-08-03): `availableTabs`/`activeTab`/`panelOpen`
// were previously untouched here — only `updatePanelFromText`'s regex
// path ever set them, so real Archy-sourced content could sit fully
// populated in these three refs while the tab bar and grid stayed
// whatever the last (or no) chat turn left them: closed, or showing
// only 'prompt' (13-06's own Known Limitations, this plan's must_haves
// GAP-FOUND). Only touches these three refs when Archy actually
// supplied non-empty content — an empty/never-granted library must not
// force the panel open on every mount.
// Largest bucket first — same reasoning as updatePanelFromText's copy:
// the tab that opens should be the one holding most of the answer.
const archyTabs: ContentTab[] = ([
['film', panelFilms.value.length],
['song', panelSongs.value.length],
['podcast', panelPodcasts.value.length],
['image', panelImages.value.length],
] as [ContentTab, number][])
.filter(([, n]) => n > 0)
.sort((a, b) => b[1] - a[1])
.map(([t]) => t)
if (archyTabs.length > 0) {
availableTabs.value = [...archyTabs, 'prompt']
if (!archyTabs.includes(activeTab.value)) activeTab.value = archyTabs[0]!
const lead = archyTabs[0]!
if (lead === 'image') panelTitle.value = `${panelImages.value.length} Images`
else if (lead === 'film') {
panelTitle.value = panelFilms.value.length === 1
? panelFilms.value[0]!.title : `${panelFilms.value.length} Films`
} else if (lead === 'song') {
panelTitle.value = panelSongs.value.length === 1
? panelSongs.value[0]!.title : `${panelSongs.value.length} Songs`
} else {
panelTitle.value = panelPodcasts.value.length === 1
? panelPodcasts.value[0]!.title : `${panelPodcasts.value.length} Podcasts`
}
panelOpen.value = true
} else if (archyContentLoading.value) {
// The turn asked the node for content and got none back. Leaving the
// previous query's heading up would claim those results answer THIS
// question; say plainly that there was nothing.
panelTitle.value = 'Nothing found'
}
archyContentLoading.value = false
}
function setActiveTab(tab: ContentTab) {
if (availableTabs.value.includes(tab)) activeTab.value = tab
}
/** Contextual content for inline cards (prompt index badges) */
function getContextualInlineContent(text: string, userQuery: string, webResults: WebSearchResult[] = []) {
let films = extractAllFilms(text)
const songs = extractAllSongs(text, userQuery)
const podcasts = extractAllPodcasts(text)
const books = extractAllBooks(text, userQuery)
const tvSeries = extractAllTVSeries(text, userQuery)
if (tvSeries.length > 0 && isTVQuery(userQuery)) {
films = films.filter(f => !f.id.startsWith('ext-'))
}
const magazineSections = extractMagazineSections(text)
const fromMarkdown = extractMarkdownLinks(text)
const boldDomains = extractBoldDomainLinks(text)
const bareDomains = extractBareDomainLinks(text)
const hasNews = webResults.length > 0 && (isNewsQuery(userQuery) || isNewsLikeResponse(text))
const newsLinks = hasNews ? webResults : []
const hasLinkableContent = fromMarkdown.length > 0 || boldDomains.length > 0 || bareDomains.length > 0
const websitesFromMd = hasLinkableContent ? fromMarkdown : []
const websitesLinks = mergeNewsResults(mergeNewsResults(websitesFromMd, boldDomains), bareDomains)
const hasWebsites = websitesLinks.length > 0
const apps = extractApps(text, userQuery)
const hasApps = apps.length > 0
const codeBlocks = extractCodeBlocks(text)
const hasCode = codeBlocks.length > 0 && (isCodeQuery(userQuery) || isCodeLikeResponse(text))
const hasNostr = isNostrQuery(userQuery) || isNostrLikeResponse(text)
const images = extractAllImages(text, userQuery)
const places = extractAllPlaces(text, userQuery)
const inlineRecipes = extractRecipes(text)
const hasRecipes = inlineRecipes.length > 0 || isRecipeLikeResponse(text)
const hasAnyOtherInline = films.length > 0 || songs.length > 0 || podcasts.length > 0 ||
books.length > 0 || tvSeries.length > 0 || images.length > 0 || places.length > 0
const hasMagazine = magazineSections.length >= 1 && (
isNewsQuery(userQuery) || isNewsLikeResponse(text) ||
/sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening|ETF|inflow|trading at|key developments|price recovery|institutional|analyst watch|market cap/i.test(text) ||
(!hasAnyOtherInline && !hasWebsites && magazineSections.length >= 2)
)
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine, hasNostr, hasApps, hasCode, hasRecipes)
return {
films: tabs.includes('film') ? films : [],
books: tabs.includes('book') ? books : [],
tvSeries: tabs.includes('tvshow') ? tvSeries : [],
images: tabs.includes('image') ? images : [],
places: tabs.includes('place') ? places : [],
songs: tabs.includes('song') ? songs : [],
podcasts: tabs.includes('podcast') ? podcasts : [],
newsLinks: tabs.includes('news') ? newsLinks : [],
websitesLinks: tabs.includes('websites') ? websitesLinks : [],
magazineSections: tabs.includes('magazine') ? magazineSections : [],
apps: tabs.includes('app') ? apps : [],
codeBlocks: tabs.includes('code') ? codeBlocks : [],
hasNostr,
}
}
// ─── Detail open/close ────────────────────────────────────────
function clearAllSelections() {
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
selectedRecipe.value = null
selectedApp.value = null
selectedWebsite.value = null
selectedMagazineSection.value = null
selectedDesignSystemItem.value = null
longFormArticle.value = null
pdfUrl.value = null
mapPlaces.value = []
}
function openFilmDetail(film: Film) { clearAllSelections(); selectedFilm.value = film }
function closeFilmDetail() { selectedFilm.value = null }
function openBookDetail(book: Book) { clearAllSelections(); selectedBook.value = book }
function closeBookDetail() { selectedBook.value = null }
function openSongDetail(song: Song) { clearAllSelections(); selectedSong.value = song }
function closeSongDetail() { selectedSong.value = null }
function openPodcastDetail(podcast: Podcast) { clearAllSelections(); selectedPodcast.value = podcast }
function closePodcastDetail() { selectedPodcast.value = null }
function openArticleDetail(article: WebSearchResult) { clearAllSelections(); selectedArticle.value = article; panelOpen.value = true }
function closeArticleDetail() { selectedArticle.value = null }
function openWebsiteDetail(website: WebSearchResult) { clearAllSelections(); selectedWebsite.value = website; panelOpen.value = true }
function closeWebsiteDetail() { selectedWebsite.value = null }
function openLongFormArticle(content: string, title?: string) { clearAllSelections(); longFormArticle.value = { content, title }; panelOpen.value = true }
function closeLongFormArticle() { longFormArticle.value = null }
function openPdfViewer(url: string, title?: string) { clearAllSelections(); pdfUrl.value = { url, title }; panelOpen.value = true }
function closePdfViewer() { pdfUrl.value = null }
function openMapView(places: Place[]) { clearAllSelections(); mapPlaces.value = places; panelOpen.value = true }
function closeMapView() { mapPlaces.value = [] }
function openMagazineSectionDetail(section: MagazineSection, index: number) {
clearAllSelections()
selectedMagazineSection.value = section
magazineSectionIndex.value = index
}
function closeMagazineSectionDetail() { selectedMagazineSection.value = null }
function navigateMagazineSection(direction: 'prev' | 'next') {
const sections = panelMagazineSections.value
if (!sections.length) return
let idx = magazineSectionIndex.value
idx += direction === 'next' ? 1 : -1
if (idx < 0) idx = sections.length - 1
if (idx >= sections.length) idx = 0
magazineSectionIndex.value = idx
selectedMagazineSection.value = sections[idx]
}
function openTVSeriesDetail(series: TVSeries) { clearAllSelections(); selectedTVSeries.value = series }
function closeTVSeriesDetail() { selectedTVSeries.value = null }
function openImageDetail(image: ImageItem) { clearAllSelections(); selectedImage.value = image }
function closeImageDetail() { selectedImage.value = null }
function openPlaceDetail(place: Place) { clearAllSelections(); selectedPlace.value = place }
function closePlaceDetail() { selectedPlace.value = null }
function openRecipeDetail(recipe: RecipeData) { clearAllSelections(); selectedRecipe.value = recipe }
function closeRecipeDetail() { selectedRecipe.value = null }
function openAppDetail(app: AppEntry) { clearAllSelections(); selectedApp.value = app }
function closeAppDetail() { selectedApp.value = null }
function openDesignSystemItem(item: DesignSystemItem) { clearAllSelections(); selectedDesignSystemItem.value = item }
function closeDesignSystemItem() { selectedDesignSystemItem.value = null }
function enterDesignSystemMode() {
panelOpen.value = true
activeTab.value = 'design-system'
availableTabs.value = ['design-system']
panelTitle.value = 'Design System'
clearAllSelections()
}
function closePanel() {
panelOpen.value = false
clearAllSelections()
activeTab.value = 'film'
availableTabs.value = []
}
function showAllFilms() {
panelFilms.value = [...demoFilms]
panelSongs.value = []
panelPodcasts.value = []
panelTitle.value = 'Your Film Library'
contentType.value = 'film'
panelOpen.value = true
clearAllSelections()
}
function showAllSongs() {
panelFilms.value = []
panelSongs.value = [...demoSongs]
panelPodcasts.value = []
panelTitle.value = 'Your Song Library'
contentType.value = 'song'
panelOpen.value = true
clearAllSelections()
}
function showAllPodcasts() {
panelFilms.value = []
panelSongs.value = []
panelPodcasts.value = [...demoPodcasts]
panelTitle.value = 'Your Podcast Library'
contentType.value = 'podcast'
panelOpen.value = true
clearAllSelections()
}
return {
panelOpen,
panelFilms,
panelBooks,
panelTVSeries,
panelImages,
panelPlaces,
panelSongs,
panelPodcasts,
archyContentActive,
archyContentLoading,
beginArchyContentLoad,
setArchyContent,
panelWebResults,
panelWebsites,
panelRecipes,
panelApps,
panelCodeBlocks,
panelMagazineSections,
panelMagazineHeroImage,
selectedFilm,
selectedBook,
selectedTVSeries,
selectedImage,
selectedPlace,
selectedSong,
selectedPodcast,
selectedArticle,
selectedRecipe,
selectedApp,
selectedWebsite,
selectedMagazineSection,
magazineSectionIndex,
panelTitle,
panelQuery,
panelResponseText,
contentType,
activeTab,
availableTabs,
setActiveTab,
extractFilmIds,
extractAllFilms,
extractAllBooks,
extractAllTVSeries,
extractSongIds,
extractAllSongs,
extractPodcastIds,
extractAllPodcasts,
extractCodeBlocks,
getContextualInlineContent,
updatePanelFromText,
stripFilmTags,
stripSongTags,
stripPodcastTags,
stripContentTags,
stripMarkdownLinks,
openFilmDetail,
closeFilmDetail,
openBookDetail,
closeBookDetail,
openSongDetail,
closeSongDetail,
openPodcastDetail,
closePodcastDetail,
openArticleDetail,
closeArticleDetail,
openWebsiteDetail,
closeWebsiteDetail,
openRecipeDetail,
closeRecipeDetail,
openAppDetail,
closeAppDetail,
openMagazineSectionDetail,
closeMagazineSectionDetail,
navigateMagazineSection,
openTVSeriesDetail,
closeTVSeriesDetail,
openImageDetail,
closeImageDetail,
openPlaceDetail,
closePlaceDetail,
selectedDesignSystemItem,
openDesignSystemItem,
closeDesignSystemItem,
longFormArticle,
openLongFormArticle,
closeLongFormArticle,
pdfUrl,
openPdfViewer,
closePdfViewer,
mapPlaces,
openMapView,
closeMapView,
enterDesignSystemMode,
closePanel,
showAllFilms,
showAllSongs,
showAllPodcasts,
}
}
@@ -0,0 +1,103 @@
import { ref } from 'vue'
import { useNostrIdentity } from './useNostrIdentity'
import { useNostr } from './useNostr'
import { exportAsMarkdown } from '@/utils/conversation-export'
import type { Conversation } from '@aiui/core/types/message'
export function useConversationShare() {
const { signEvent, pubkey, isLoggedIn } = useNostrIdentity()
const { publishEvent } = useNostr()
const isSharing = ref(false)
const shareError = ref<string | null>(null)
const sharedNaddr = ref<string | null>(null)
async function shareAsNostrArticle(
conversation: Conversation,
options: { encrypt?: boolean; recipientPubkey?: string } = {}
): Promise<string | null> {
if (!isLoggedIn.value || !pubkey.value) {
shareError.value = 'Please log in with a Nostr extension (NIP-07) first.'
return null
}
isSharing.value = true
shareError.value = null
try {
const markdown = exportAsMarkdown(conversation)
const dTag = `aiui-conv-${conversation.id}`
const title = conversation.title || 'AIUI Conversation'
let content = markdown
if (options.encrypt && options.recipientPubkey) {
// NIP-44 encryption via extension
const ext = (window as unknown as Record<string, unknown>).nostr as {
nip44?: { encrypt(pubkey: string, plaintext: string): Promise<string> }
} | undefined
if (ext?.nip44?.encrypt) {
content = await ext.nip44.encrypt(options.recipientPubkey, markdown)
} else {
shareError.value = 'NIP-44 encryption not supported by your extension.'
return null
}
}
const tags: string[][] = [
['d', dTag],
['title', title],
['published_at', String(Math.floor(conversation.createdAt / 1000))],
['t', 'aiui'],
['t', 'conversation'],
]
if (conversation.model) {
tags.push(['t', conversation.model])
}
if (options.encrypt && options.recipientPubkey) {
tags.push(['p', options.recipientPubkey])
tags.push(['encrypted', 'nip44'])
}
const unsigned = {
kind: 30023, // Long-form article
pubkey: pubkey.value,
created_at: Math.floor(Date.now() / 1000),
content,
tags,
}
const signed = await signEvent(unsigned)
if (!signed) {
shareError.value = 'Failed to sign event.'
return null
}
await publishEvent(signed)
// Build naddr
const naddr = buildNaddr(dTag, pubkey.value, signed.kind)
sharedNaddr.value = naddr
return naddr
} catch (err) {
shareError.value = err instanceof Error ? err.message : 'Failed to share conversation.'
return null
} finally {
isSharing.value = false
}
}
function buildNaddr(dTag: string, authorPubkey: string, kind: number): string {
// Simplified naddr encoding — in production use a proper bech32 library
const parts = [dTag, authorPubkey, String(kind)]
return `nostr:naddr1${btoa(parts.join(':')).replace(/=/g, '')}`
}
return {
isSharing,
shareError,
sharedNaddr,
shareAsNostrArticle,
}
}
@@ -0,0 +1,154 @@
import { ref, computed } from 'vue'
export interface ConversationTemplate {
id: string
title: string
description: string
systemPrompt: string
firstMessage: string
icon: string
category: string
model?: string
}
const STORAGE_KEY = 'aiui-conversation-templates'
const builtInTemplates: ConversationTemplate[] = [
{
id: 'tpl-bitcoin-deep-dive',
title: 'Bitcoin Deep Dive',
description: 'Explore Bitcoin technology, economics, and philosophy in depth.',
systemPrompt: 'You are a knowledgeable Bitcoin educator. Explain concepts clearly, reference primary sources (whitepaper, BIPs), and maintain a cypherpunk perspective. Focus on sovereignty, decentralization, and sound money principles.',
firstMessage: 'I want to understand Bitcoin at a deeper level. Can you start by explaining how proof-of-work creates trustless consensus?',
icon: '₿',
category: 'Bitcoin',
},
{
id: 'tpl-film-analysis',
title: 'Film Analysis',
description: 'Analyze films through the lens of cinematography, narrative, and themes.',
systemPrompt: 'You are a film critic and scholar. Discuss films with attention to cinematography, direction, narrative structure, themes, and cultural context. Reference specific scenes and techniques.',
firstMessage: 'Let\'s analyze a film together. I\'d like to discuss the visual storytelling in Blade Runner 2049.',
icon: '🎬',
category: 'Creative',
},
{
id: 'tpl-nostr-onboarding',
title: 'Nostr Onboarding',
description: 'Get started with the Nostr protocol and decentralized social media.',
systemPrompt: 'You are a Nostr protocol expert. Help users understand key concepts: keypairs, relays, NIPs, clients, and the ecosystem. Be encouraging and practical.',
firstMessage: 'I\'m new to Nostr. Can you explain what it is and how I can get started?',
icon: '🔑',
category: 'Technology',
},
{
id: 'tpl-music-discovery',
title: 'Music Discovery',
description: 'Discover new music based on your tastes and explore genres.',
systemPrompt: 'You are a music curator with deep knowledge across all genres. Recommend music based on user preferences, explain what makes artists and albums special, and connect musical lineages.',
firstMessage: 'I love math rock and post-rock. What are some artists I should check out that push the boundaries of these genres?',
icon: '🎵',
category: 'Creative',
},
{
id: 'tpl-code-review',
title: 'Code Review',
description: 'Get constructive feedback on your code with best practices.',
systemPrompt: 'You are a senior software engineer conducting code reviews. Focus on readability, performance, security, and maintainability. Be constructive and specific.',
firstMessage: 'I\'d like you to review some code I\'m working on. I\'ll paste it in the next message.',
icon: '💻',
category: 'Technology',
},
{
id: 'tpl-privacy-guide',
title: 'Privacy & Security Guide',
description: 'Learn about digital privacy, operational security, and freedom tech.',
systemPrompt: 'You are a digital privacy expert. Help users improve their online privacy and security. Recommend open-source tools, explain threat models, and promote self-sovereign digital identity.',
firstMessage: 'I want to improve my digital privacy. Where should I start?',
icon: '🛡️',
category: 'Privacy',
},
]
const customTemplates = ref<ConversationTemplate[]>([])
function loadCustomTemplates() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) customTemplates.value = JSON.parse(stored)
} catch { /* ignore */ }
}
function saveCustomTemplates() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(customTemplates.value))
}
loadCustomTemplates()
export function useConversationTemplates() {
const allTemplates = computed(() => [...builtInTemplates, ...customTemplates.value])
const categories = computed(() => {
const cats = new Set(allTemplates.value.map((t) => t.category))
return [...cats].sort()
})
function addTemplate(template: Omit<ConversationTemplate, 'id'>) {
const newTemplate: ConversationTemplate = {
...template,
id: `tpl-custom-${crypto.randomUUID()}`,
}
customTemplates.value.push(newTemplate)
saveCustomTemplates()
return newTemplate
}
function removeTemplate(id: string) {
customTemplates.value = customTemplates.value.filter((t) => t.id !== id)
saveCustomTemplates()
}
function exportTemplates(): string {
return JSON.stringify(allTemplates.value, null, 2)
}
function importTemplates(json: string): number {
try {
const imported = JSON.parse(json) as ConversationTemplate[]
if (!Array.isArray(imported)) return 0
let count = 0
for (const t of imported) {
if (t.title && t.systemPrompt && t.firstMessage) {
const exists = allTemplates.value.some((e) => e.id === t.id)
if (!exists) {
customTemplates.value.push({
...t,
id: t.id || `tpl-imported-${crypto.randomUUID()}`,
})
count++
}
}
}
if (count > 0) saveCustomTemplates()
return count
} catch {
return 0
}
}
function getByCategory(category: string): ConversationTemplate[] {
return allTemplates.value.filter((t) => t.category === category)
}
return {
templates: allTemplates,
categories,
customTemplates,
builtInTemplates,
addTemplate,
removeTemplate,
exportTemplates,
importTemplates,
getByCategory,
}
}
@@ -0,0 +1,29 @@
const inflight = new Map<string, Promise<Response>>()
export function useDeduplicatedFetch() {
async function dedupFetch(url: string, options?: RequestInit): Promise<Response> {
// Generate a cache key from URL + body
const bodyStr = options?.body ? String(options.body) : ''
const key = `${options?.method ?? 'GET'}:${url}:${bodyStr}`
const existing = inflight.get(key)
if (existing) return existing.then(r => r.clone())
const controller = new AbortController()
const mergedOptions = { ...options, signal: controller.signal }
const promise = fetch(url, mergedOptions).finally(() => {
inflight.delete(key)
})
inflight.set(key, promise)
return promise
}
function cancelAll() {
inflight.clear()
}
return { dedupFetch, cancelAll }
}
@@ -0,0 +1,113 @@
import { ref, watch } from 'vue'
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
import { mockPodcasts } from '@/mocks/podcasts'
import type { Film, Song, Podcast } from '@aiui/core/types/content'
// Demo-site content pack (operator decision 2026-08-07): demo/dev only.
const demoFilms: Film[] = (import.meta.env.DEV || import.meta.env.VITE_DEMO_CONTENT === 'true') ? mockFilms : []
const demoSongs: Song[] = (import.meta.env.DEV || import.meta.env.VITE_DEMO_CONTENT === 'true') ? mockSongs : []
const demoPodcasts: Podcast[] = (import.meta.env.DEV || import.meta.env.VITE_DEMO_CONTENT === 'true') ? mockPodcasts : []
export interface SearchResult {
type: 'film' | 'song' | 'podcast'
title: string
subtitle: string
id: string
data: unknown
}
const query = ref('')
const results = ref<SearchResult[]>([])
const isSearching = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function searchLibrary(q: string): SearchResult[] {
const lower = q.toLowerCase()
const matched: SearchResult[] = []
for (const film of demoFilms) {
if (
film.title.toLowerCase().includes(lower) ||
film.director.toLowerCase().includes(lower) ||
film.genres.some(g => g.toLowerCase().includes(lower))
) {
matched.push({
type: 'film',
title: film.title,
subtitle: `${film.year} · ${film.director}`,
id: film.id,
data: film,
})
}
}
for (const song of demoSongs) {
if (
song.title.toLowerCase().includes(lower) ||
song.artist.toLowerCase().includes(lower) ||
(song.album ?? '').toLowerCase().includes(lower)
) {
matched.push({
type: 'song',
title: song.title,
subtitle: song.artist,
id: song.id,
data: song,
})
}
}
for (const podcast of demoPodcasts) {
if (
podcast.title.toLowerCase().includes(lower) ||
(podcast.host ?? '').toLowerCase().includes(lower)
) {
matched.push({
type: 'podcast',
title: podcast.title,
subtitle: podcast.host ?? 'Unknown',
id: podcast.id,
data: podcast,
})
}
}
return matched.slice(0, 20)
}
export function useFederatedSearch() {
function search(q: string) {
query.value = q
}
function clear() {
query.value = ''
results.value = []
isSearching.value = false
}
watch(query, (q) => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!q.trim()) {
results.value = []
isSearching.value = false
return
}
isSearching.value = true
debounceTimer = setTimeout(() => {
results.value = searchLibrary(q.trim())
isSearching.value = false
}, 150)
})
return {
query,
results,
isSearching,
search,
clear,
}
}
@@ -0,0 +1,53 @@
import { onMounted, onUnmounted, type Ref } from 'vue'
export function useFocusTrap(containerRef: Ref<HTMLElement | null>) {
function getFocusableElements(): HTMLElement[] {
const container = containerRef.value
if (!container) return []
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
)
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
// Bubble up for close handlers
return
}
if (e.key !== 'Tab') return
const focusable = getFocusableElements()
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
// Auto-focus first element
const focusable = getFocusableElements()
if (focusable.length > 0) {
focusable[0].focus()
}
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown)
})
}
@@ -0,0 +1,30 @@
import { useSettingsStore } from '@/stores/settings'
export function useHaptics() {
const isSupported = 'vibrate' in navigator
function vibrate(pattern: number | number[]) {
if (!isSupported) return
try {
const settings = useSettingsStore()
if (settings.settings.notificationsEnabled === false) return // reuse notifications toggle
navigator.vibrate(pattern)
} catch { /* ignore */ }
}
function messageSend() { vibrate(10) }
function favoriteToggle() { vibrate(15) }
function error() { vibrate([50, 50, 50]) }
function pullRefresh() { vibrate(20) }
function longPress() { vibrate(20) }
return {
isSupported,
vibrate,
messageSend,
favoriteToggle,
error,
pullRefresh,
longPress,
}
}
@@ -0,0 +1,571 @@
import { apiFetch } from '@/utils/api-fetch'
type TmdbResult = { posterUrl: string | null; backdropUrl: string | null }
const memoryCache = new Map<string, TmdbResult>()
const SESSION_KEY = 'aiui-poster-cache'
const failedUrls = new Set<string>()
const musicCoverCache = new Map<string, string>()
const SESSION_MUSIC_KEY = 'aiui-music-cover-cache'
const podcastCoverCache = new Map<string, string>()
const SESSION_PODCAST_KEY = 'aiui-podcast-cover-cache'
function musicCacheKey(artist: string, title: string): string {
return `${artist.toLowerCase().trim()}|${title.toLowerCase().trim()}`
}
function podcastCacheKey(title: string, host?: string): string {
return `${title.toLowerCase().trim()}|${(host ?? '').toLowerCase().trim()}`
}
function loadMusicCache(): void {
try {
const raw = sessionStorage.getItem(SESSION_MUSIC_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Record<string, string>
Object.entries(parsed).forEach(([k, v]) => musicCoverCache.set(k, v))
}
} catch { /* ignore */ }
}
function saveMusicCache(): void {
try {
const entries = [...musicCoverCache.entries()].slice(-300)
sessionStorage.setItem(SESSION_MUSIC_KEY, JSON.stringify(Object.fromEntries(entries)))
} catch { /* ignore */ }
}
loadMusicCache()
function cacheKey(t: string, y?: number): string {
return `${t.toLowerCase().trim()}|${y ?? ''}`
}
function loadSessionCache(): void {
try {
const raw = sessionStorage.getItem(SESSION_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Record<string, TmdbResult>
Object.entries(parsed).forEach(([k, v]) => memoryCache.set(k, v))
}
} catch { /* ignore */ }
}
function saveSessionCache(): void {
try {
const entries = [...memoryCache.entries()].slice(-200)
sessionStorage.setItem(SESSION_KEY, JSON.stringify(Object.fromEntries(entries)))
} catch { /* ignore */ }
}
loadSessionCache()
function loadPodcastCache(): void {
try {
const raw = sessionStorage.getItem(SESSION_PODCAST_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Record<string, string>
Object.entries(parsed).forEach(([k, v]) => podcastCoverCache.set(k, v))
}
} catch { /* ignore */ }
}
function savePodcastCache(): void {
try {
const entries = [...podcastCoverCache.entries()].slice(-200)
sessionStorage.setItem(SESSION_PODCAST_KEY, JSON.stringify(Object.fromEntries(entries)))
} catch { /* ignore */ }
}
loadPodcastCache()
/** Helper to build a consistent text-only SVG fallback (matches Film/TV Series style) */
function buildSquareFallback(
label: string, title: string, subtitle: string | undefined,
hue: number, sat: number,
): string {
const cx = 100
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<rect width="200" height="200" fill="hsl(${hue}, ${sat}%, 28%)"/>
<rect x="3" y="3" width="194" height="194" rx="12" fill="none" stroke="hsl(${hue}, ${sat + 5}%, 38%)" stroke-width="1"/>
<text x="${cx}" y="82" text-anchor="middle" fill="hsl(${hue}, ${sat}%, 48%)" font-family="system-ui,sans-serif" font-size="10" font-weight="400" letter-spacing="4">${label}</text>
<line x1="60" y1="92" x2="140" y2="92" stroke="hsl(${hue}, ${sat + 5}%, 40%)" stroke-width="1"/>
<text x="${cx}" y="118" text-anchor="middle" fill="hsl(${hue}, ${sat + 15}%, 78%)" font-family="system-ui,sans-serif" font-size="13" font-weight="700">${escapeXml(title.length > 18 ? title.slice(0, 16) + '…' : title)}</text>
${subtitle ? `<text x="${cx}" y="138" text-anchor="middle" fill="hsl(${hue}, ${sat}%, 60%)" font-family="system-ui,sans-serif" font-size="10" font-weight="300">${escapeXml(subtitle.length > 22 ? subtitle.slice(0, 20) + '…' : subtitle)}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
export function generatePodcastCoverFallback(title: string, host?: string): string {
const hue = [...(title + (host ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
return buildSquareFallback('PODCAST', title, host, hue, 30)
}
export function generateSongCoverFallback(title: string, artist?: string): string {
const hue = [...(title + (artist ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
return buildSquareFallback('MUSIC', title, artist, hue, 30)
}
export function generateNewsFallback(title: string, source?: string): string {
const hue = [...(title + (source ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
return buildSquareFallback('NEWS', title, source, hue, 22)
}
export function generateImageFallback(title: string): string {
const hue = [...title].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
return buildSquareFallback('IMAGE', title, undefined, hue, 20)
}
/** Website fallback — domain-based */
export function generateWebsiteFallback(title: string, domain?: string): string {
const hue = [...(title + (domain ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
return buildSquareFallback('WEBSITE', title, domain, hue, 25)
}
/** Film poster fallback — cinematic sans-serif */
export function generatePosterFallback(title: string, year?: number): string {
const hue = [...title].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 342 513">
<rect width="342" height="513" fill="hsl(${hue}, 25%, 28%)"/>
<rect x="3" y="3" width="336" height="507" rx="6" fill="none" stroke="hsl(${hue}, 30%, 38%)" stroke-width="1"/>
<text x="171" y="210" text-anchor="middle" fill="hsl(${hue}, 25%, 48%)" font-family="'Helvetica Neue',Helvetica,Arial,sans-serif" font-size="13" font-weight="400" letter-spacing="5">FILM</text>
<line x1="121" y1="225" x2="221" y2="225" stroke="hsl(${hue}, 30%, 40%)" stroke-width="1"/>
<text x="171" y="265" text-anchor="middle" fill="hsl(${hue}, 45%, 78%)" font-family="'Helvetica Neue',Helvetica,Arial,sans-serif" font-size="18" font-weight="700">
${escapeXml(title.length > 20 ? title.slice(0, 18) + '…' : title)}
</text>
${year ? `<text x="171" y="295" text-anchor="middle" fill="hsl(${hue}, 30%, 60%)" font-family="'Helvetica Neue',Helvetica,Arial,sans-serif" font-size="14" font-weight="300">${year}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
/** TV Series poster fallback — modern grotesque */
export function generateTVSeriesFallback(title: string, year?: number): string {
const hue = [...title].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 342 513">
<rect width="342" height="513" fill="hsl(${hue}, 22%, 28%)"/>
<rect x="3" y="3" width="336" height="507" rx="6" fill="none" stroke="hsl(${hue}, 28%, 38%)" stroke-width="1"/>
<text x="171" y="210" text-anchor="middle" fill="hsl(${hue}, 22%, 48%)" font-family="'SF Pro Display',system-ui,sans-serif" font-size="13" font-weight="400" letter-spacing="5">SERIES</text>
<line x1="111" y1="225" x2="231" y2="225" stroke="hsl(${hue}, 28%, 40%)" stroke-width="1"/>
<text x="171" y="265" text-anchor="middle" fill="hsl(${hue}, 40%, 78%)" font-family="'SF Pro Display',system-ui,sans-serif" font-size="18" font-weight="700">
${escapeXml(title.length > 20 ? title.slice(0, 18) + '…' : title)}
</text>
${year ? `<text x="171" y="295" text-anchor="middle" fill="hsl(${hue}, 28%, 60%)" font-family="'SF Pro Display',system-ui,sans-serif" font-size="14" font-weight="300">${year}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
async function fetchTmdbGeneric(
endpoint: string,
title: string,
year?: number,
): Promise<TmdbResult> {
const key = `${endpoint}:${cacheKey(title, year)}`
const cached = memoryCache.get(key)
if (cached) return cached
const empty: TmdbResult = { posterUrl: null, backdropUrl: null }
try {
const params = new URLSearchParams({ q: title.trim() })
if (year && year > 0) params.set('y', String(year))
const res = await apiFetch(`/api/tmdb/${endpoint}?${params}`)
if (!res.ok) return empty
const data = (await res.json()) as { posterUrl?: string | null; backdropUrl?: string | null }
const result: TmdbResult = {
posterUrl: data.posterUrl ?? null,
backdropUrl: data.backdropUrl ?? null,
}
if (result.posterUrl || result.backdropUrl) {
memoryCache.set(key, result)
saveSessionCache()
}
return result
} catch {
return empty
}
}
export async function fetchTmdbPoster(
title: string,
year?: number,
): Promise<TmdbResult> {
return fetchTmdbGeneric('search', title, year)
}
export async function handleImgError(
e: Event,
title: string,
year?: number,
): Promise<void> {
const img = e.target as HTMLImageElement
if (img.dataset.fallback === 'done') return
const originalSrc = img.src
failedUrls.add(originalSrc)
const key = cacheKey(title, year)
const cached = memoryCache.get(key)
if (cached?.posterUrl && cached.posterUrl !== originalSrc) {
img.dataset.fallback = 'tmdb'
img.src = cached.posterUrl
return
}
if (img.dataset.fallback !== 'tmdb') {
const { posterUrl } = await fetchTmdbPoster(title, year)
if (posterUrl && posterUrl !== originalSrc) {
img.dataset.fallback = 'tmdb'
img.src = posterUrl
return
}
}
if (img.dataset.fallback !== 'wiki') {
const wiki = await fetchWikipediaImage(title, 'film')
if (wiki) {
img.dataset.fallback = 'wiki'
img.src = wiki
return
}
}
img.dataset.fallback = 'done'
img.src = generatePosterFallback(title, year)
}
export function isUrlFailed(url: string | undefined): boolean {
return !!url && failedUrls.has(url)
}
export async function fetchTmdbTVPoster(
title: string,
year?: number,
): Promise<TmdbResult> {
return fetchTmdbGeneric('search-tv', title, year)
}
/** Fetch podcast artwork from iTunes Search API (free, no key). Returns hi-res URL (600x600). */
export async function fetchPodcastCover(
title: string,
host?: string,
): Promise<string | null> {
const key = podcastCacheKey(title, host)
const cached = podcastCoverCache.get(key)
if (cached) return cached
try {
const term = host ? `${title} ${host}` : title
const res = await fetch(
`https://itunes.apple.com/search?term=${encodeURIComponent(term.trim())}&media=podcast&limit=3`,
)
if (!res.ok) return null
const data = (await res.json()) as { results?: { artworkUrl100?: string }[] }
const first = data.results?.[0]
const url = first?.artworkUrl100
if (!url) return null
const hiRes = url.replace(/100x100/g, '600x600')
podcastCoverCache.set(key, hiRes)
savePodcastCache()
return hiRes
} catch {
return null
}
}
/** Fetch album artwork from iTunes Search API (free, no key). Returns hi-res URL (600x600). */
const bookCoverCache = new Map<string, string>()
const SESSION_BOOK_KEY = 'aiui-book-cover-cache'
function bookCacheKey(title: string, author: string): string {
return `${title.toLowerCase().trim()}|${author.toLowerCase().trim()}`
}
function loadBookCache(): void {
try {
const raw = sessionStorage.getItem(SESSION_BOOK_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Record<string, string>
Object.entries(parsed).forEach(([k, v]) => bookCoverCache.set(k, v))
}
} catch { /* ignore */ }
}
function saveBookCache(): void {
try {
const entries = [...bookCoverCache.entries()].slice(-200)
sessionStorage.setItem(SESSION_BOOK_KEY, JSON.stringify(Object.fromEntries(entries)))
} catch { /* ignore */ }
}
loadBookCache()
export function generateBookCoverFallback(title: string, author?: string): string {
const hue = [...(title + (author ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 342 513">
<rect width="342" height="513" fill="hsl(${hue}, 20%, 28%)"/>
<rect x="3" y="3" width="336" height="507" rx="6" fill="none" stroke="hsl(${hue}, 25%, 38%)" stroke-width="1"/>
<text x="171" y="210" text-anchor="middle" fill="hsl(${hue}, 18%, 48%)" font-family="Georgia,'Times New Roman',serif" font-size="13" font-weight="400" letter-spacing="5">BOOK</text>
<line x1="121" y1="225" x2="221" y2="225" stroke="hsl(${hue}, 22%, 40%)" stroke-width="1"/>
<text x="171" y="265" text-anchor="middle" fill="hsl(${hue}, 35%, 78%)" font-family="Georgia,'Times New Roman',serif" font-size="18" font-weight="700">
${escapeXml(title.length > 20 ? title.slice(0, 18) + '…' : title)}
</text>
${author ? `<text x="171" y="295" text-anchor="middle" fill="hsl(${hue}, 22%, 60%)" font-family="Georgia,'Times New Roman',serif" font-size="14" font-weight="300">${escapeXml(author.length > 24 ? author.slice(0, 22) + '…' : author)}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
/** Fetch book cover from Open Library Covers API (free, no key). */
export async function fetchBookCover(
title: string,
author: string,
): Promise<string | null> {
const key = bookCacheKey(title, author)
const cached = bookCoverCache.get(key)
if (cached) return cached
try {
const q = `${title} ${author}`.trim()
const res = await fetch(
`https://openlibrary.org/search.json?q=${encodeURIComponent(q)}&limit=1&fields=cover_i`,
)
if (!res.ok) return null
const data = (await res.json()) as { docs?: { cover_i?: number }[] }
const coverId = data.docs?.[0]?.cover_i
if (!coverId) return null
const url = `https://covers.openlibrary.org/b/id/${coverId}-L.jpg`
bookCoverCache.set(key, url)
saveBookCache()
return url
} catch {
return null
}
}
export function generatePlaceFallback(name: string, cuisine?: string): string {
const hue = [...(name + (cuisine ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
return buildSquareFallback('PLACE', name, cuisine, hue, 20)
}
export function generateRecipeFallback(title: string, time?: string): string {
const hue = [...title].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
return buildSquareFallback('RECIPE', title, time, hue, 30)
}
/** Place image: Wikipedia (try restaurant/place name) */
export async function fetchPlaceImage(
name: string,
city?: string,
): Promise<string | null> {
// Try Wikipedia with city disambiguation first
if (city) {
const wiki = await fetchWikipediaImage(name, city)
if (wiki) return wiki
}
// Try just the name (for well-known places/chains)
const wiki = await fetchWikipediaImage(name, 'restaurant')
if (wiki) return wiki
return null
}
export async function fetchMusicCover(
title: string,
artist: string,
album?: string,
): Promise<string | null> {
const key = musicCacheKey(artist, title)
const cached = musicCoverCache.get(key)
if (cached) return cached
// Try local API proxy first (works in dev with Vite middleware)
try {
const base = import.meta.env.BASE_URL || '/'
const params = new URLSearchParams({ q: title, title, artist })
const wlRes = await apiFetch(`${base}api/music/search?${params}`)
if (wlRes.ok) {
const wlData = (await wlRes.json()) as { coverUrl?: string }
if (wlData.coverUrl) {
musicCoverCache.set(key, wlData.coverUrl)
saveMusicCache()
return wlData.coverUrl
}
}
} catch {
// Fall through to direct Wavlake
}
// Try Wavlake API directly (works in production without Vite middleware)
try {
const term = `${title} ${artist}`.trim()
const res = await fetch(
`https://wavlake.com/api/v1/content/search?term=${encodeURIComponent(term)}`,
{ headers: { Accept: 'application/json' } },
)
if (res.ok) {
const items = (await res.json()) as { type: string; albumArtUrl?: string; artistArtUrl?: string }[]
if (Array.isArray(items)) {
const track = items.find(i => i.type === 'track')
const coverUrl = track?.albumArtUrl ?? track?.artistArtUrl
if (coverUrl) {
musicCoverCache.set(key, coverUrl)
saveMusicCache()
return coverUrl
}
}
}
} catch {
// Fall through to iTunes
}
// Fallback: iTunes Search API
try {
const term = `${artist} ${title}`.trim().replace(/\s+/g, '+')
const res = await fetch(
`https://itunes.apple.com/search?term=${encodeURIComponent(term)}&media=music&limit=3`,
)
if (!res.ok) return null
const data = (await res.json()) as { results?: { artworkUrl100?: string }[] }
const first = data.results?.[0]
const url = first?.artworkUrl100
if (!url) return null
const hiRes = url.replace(/100x100/g, '600x600')
musicCoverCache.set(key, hiRes)
saveMusicCache()
return hiRes
} catch {
return null
}
}
// ---------------------------------------------------------------------------
// Wikipedia image source (free, no key)
// ---------------------------------------------------------------------------
const wikiImageCache = new Map<string, string | null>()
/** Fetch an image from Wikipedia REST API. Free, no API key needed. */
export async function fetchWikipediaImage(
title: string,
disambiguator?: string,
): Promise<string | null> {
const key = `${title.toLowerCase().trim()}|${(disambiguator ?? '').toLowerCase()}`
if (wikiImageCache.has(key)) return wikiImageCache.get(key) ?? null
const tryTitle = async (t: string): Promise<string | null> => {
try {
const encoded = encodeURIComponent(t.trim().replace(/\s+/g, '_'))
const res = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encoded}`)
if (!res.ok) return null
const data = (await res.json()) as {
thumbnail?: { source?: string }
originalimage?: { source?: string }
}
return data.originalimage?.source ?? data.thumbnail?.source ?? null
} catch {
return null
}
}
let url: string | null = null
// Try disambiguated title first (more specific), then exact title
if (disambiguator) {
url = await tryTitle(`${title} (${disambiguator})`)
}
if (!url) {
url = await tryTitle(title)
}
wikiImageCache.set(key, url)
return url
}
// ---------------------------------------------------------------------------
// Google Books image source (free, no key)
// ---------------------------------------------------------------------------
/** Fetch book cover from Google Books API. Free, no API key needed. */
export async function fetchGoogleBooksImage(
title: string,
author?: string,
): Promise<string | null> {
const key = bookCacheKey(title, author ?? '')
const gKey = `gbooks:${key}`
if (wikiImageCache.has(gKey)) return wikiImageCache.get(gKey) ?? null
try {
const q = author ? `intitle:${title}+inauthor:${author}` : `intitle:${title}`
const res = await fetch(
`https://www.googleapis.com/books/v1/volumes?q=${encodeURIComponent(q)}&maxResults=1`,
)
if (!res.ok) return null
const data = (await res.json()) as {
items?: { volumeInfo?: { imageLinks?: { thumbnail?: string; smallThumbnail?: string } } }[]
}
const links = data.items?.[0]?.volumeInfo?.imageLinks
let url = links?.thumbnail ?? links?.smallThumbnail ?? null
// Google Books returns http URLs and small sizes — upgrade
if (url) {
url = url.replace(/^http:/, 'https:').replace(/&edge=curl/g, '')
// Request larger zoom
if (!url.includes('zoom=')) url += '&zoom=2'
}
wikiImageCache.set(gKey, url)
return url
} catch {
return null
}
}
// ---------------------------------------------------------------------------
// Chained fetchers — try multiple sources in order
// ---------------------------------------------------------------------------
/** Film image: TMDB → Wikipedia */
export async function fetchFilmImage(
title: string,
year?: number,
): Promise<{ posterUrl: string | null; backdropUrl: string | null }> {
// Try TMDB first
const tmdb = await fetchTmdbPoster(title, year)
if (tmdb.posterUrl || tmdb.backdropUrl) return tmdb
// Fall back to Wikipedia
const wiki = await fetchWikipediaImage(title, 'film')
if (wiki) return { posterUrl: wiki, backdropUrl: null }
return { posterUrl: null, backdropUrl: null }
}
/** TV series image: TMDB → Wikipedia */
export async function fetchTVImage(
title: string,
year?: number,
): Promise<{ posterUrl: string | null; backdropUrl: string | null }> {
const tmdb = await fetchTmdbTVPoster(title, year)
if (tmdb.posterUrl || tmdb.backdropUrl) return tmdb
const wiki = await fetchWikipediaImage(title, 'TV series')
if (wiki) return { posterUrl: wiki, backdropUrl: null }
return { posterUrl: null, backdropUrl: null }
}
/** Book image: Open Library → Google Books → Wikipedia */
export async function fetchBookImage(
title: string,
author?: string,
): Promise<string | null> {
// Try Open Library first
const ol = await fetchBookCover(title, author ?? '')
if (ol) return ol
// Try Google Books
const gb = await fetchGoogleBooksImage(title, author)
if (gb) return gb
// Try Wikipedia
const wiki = await fetchWikipediaImage(title, 'novel')
return wiki
}
@@ -0,0 +1,33 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
export function useLandscape() {
const windowWidth = ref(window.innerWidth)
const windowHeight = ref(window.innerHeight)
const isLandscape = computed(() => windowWidth.value > windowHeight.value)
const isMobile = computed(() => Math.min(windowWidth.value, windowHeight.value) < 768)
const isMobileLandscape = computed(() => isMobile.value && isLandscape.value)
function onResize() {
windowWidth.value = window.innerWidth
windowHeight.value = window.innerHeight
}
onMounted(() => {
window.addEventListener('resize', onResize)
window.addEventListener('orientationchange', onResize)
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
window.removeEventListener('orientationchange', onResize)
})
return {
isLandscape,
isMobile,
isMobileLandscape,
windowWidth,
windowHeight,
}
}
@@ -0,0 +1,40 @@
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
const PLACEHOLDER = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSIjMWExYTFhIi8+PC9zdmc+'
export function useLazyImage(imageRef: Ref<HTMLImageElement | null>, src: string) {
const isLoaded = ref(false)
const currentSrc = ref(PLACEHOLDER)
let observer: IntersectionObserver | null = null
function onLoad() {
isLoaded.value = true
}
onMounted(() => {
const el = imageRef.value
if (!el) return
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
currentSrc.value = src
el.src = src
el.addEventListener('load', onLoad, { once: true })
observer?.disconnect()
}
}
},
{ rootMargin: '200px' }
)
observer.observe(el)
})
onUnmounted(() => {
observer?.disconnect()
})
return { isLoaded, currentSrc, placeholder: PLACEHOLDER }
}
@@ -0,0 +1,55 @@
import { ref } from 'vue'
const lnurlAuthUrl = ref<string | null>(null)
const isAuthenticated = ref(false)
const lightningIdentity = ref<string | null>(null)
const STORAGE_KEY = 'aiui-lnurl-auth-identity'
function loadIdentity() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
lightningIdentity.value = stored
isAuthenticated.value = true
}
} catch { /* ignore */ }
}
loadIdentity()
export function useLnurlAuth() {
function generateLnurlAuthUrl(): string {
// Generate a challenge for LNURL-auth
// In production, this would come from the server
const challenge = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
const url = `lnurl-auth://auth?tag=login&k1=${challenge}`
lnurlAuthUrl.value = url
return url
}
function setIdentity(pubkey: string) {
lightningIdentity.value = pubkey
isAuthenticated.value = true
localStorage.setItem(STORAGE_KEY, pubkey)
}
function clearIdentity() {
lightningIdentity.value = null
isAuthenticated.value = false
localStorage.removeItem(STORAGE_KEY)
lnurlAuthUrl.value = null
}
return {
lnurlAuthUrl,
isAuthenticated,
lightningIdentity,
generateLnurlAuthUrl,
setIdentity,
clearIdentity,
}
}
@@ -0,0 +1,66 @@
/**
* Lazy KaTeX math rendering for markdown content.
* Detects $...$ (inline) and $$...$$ (block) LaTeX.
* Falls back to raw <code> on invalid LaTeX.
*/
let katexModule: typeof import('katex') | null = null
let katexLoading: Promise<typeof import('katex')> | null = null
async function loadKaTeX() {
if (katexModule) return katexModule
if (katexLoading) return katexLoading
katexLoading = import('katex').then((m) => {
katexModule = m
// Inject KaTeX CSS
if (!document.getElementById('katex-css')) {
const link = document.createElement('link')
link.id = 'katex-css'
link.rel = 'stylesheet'
link.href = new URL('katex/dist/katex.min.css', import.meta.url).toString()
document.head.appendChild(link)
}
return m
})
return katexLoading
}
const BLOCK_RE = /\$\$([\s\S]+?)\$\$/g
const INLINE_RE = /\$([^\n$]+?)\$/g
function renderKaTeX(latex: string, displayMode: boolean): string {
if (!katexModule) return `<code>${escapeForHtml(latex)}</code>`
try {
return katexModule.default.renderToString(latex, {
displayMode,
throwOnError: false,
output: 'html',
})
} catch {
return `<code>${escapeForHtml(latex)}</code>`
}
}
function escapeForHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
export function hasMath(text: string): boolean {
return /\$\$[\s\S]+?\$\$/.test(text) || /\$[^\n$]+?\$/.test(text)
}
export async function renderMathInHtml(html: string): Promise<string> {
await loadKaTeX()
// Block math first ($$...$$)
let result = html.replace(BLOCK_RE, (_, latex) => {
return `<div class="katex-block my-4 text-center overflow-x-auto">${renderKaTeX(latex.trim(), true)}</div>`
})
// Inline math ($...$)
result = result.replace(INLINE_RE, (_, latex) => {
return renderKaTeX(latex.trim(), false)
})
return result
}
@@ -0,0 +1,96 @@
/**
* Lazy Mermaid diagram rendering for ```mermaid code blocks.
* Dark theme matching glass design, cached renders.
*/
let mermaidModule: typeof import('mermaid') | null = null
let mermaidLoading: Promise<typeof import('mermaid')> | null = null
let mermaidInitialized = false
async function loadMermaid() {
if (mermaidModule) return mermaidModule
if (mermaidLoading) return mermaidLoading
mermaidLoading = import('mermaid').then((m) => {
mermaidModule = m
return m
})
return mermaidLoading
}
function initMermaid() {
if (mermaidInitialized || !mermaidModule) return
mermaidModule.default.initialize({
startOnLoad: false,
theme: 'dark',
themeVariables: {
primaryColor: '#F7931A',
primaryTextColor: '#fff',
primaryBorderColor: '#F7931A',
lineColor: '#666',
secondaryColor: '#1a1a1a',
tertiaryColor: '#111',
background: '#0a0a0a',
mainBkg: '#1a1a1a',
nodeBorder: '#444',
clusterBkg: '#111',
clusterBorder: '#333',
titleColor: '#ddd',
edgeLabelBackground: '#1a1a1a',
},
fontFamily: 'Inter, system-ui, sans-serif',
fontSize: 13,
})
mermaidInitialized = true
}
// Cache rendered diagrams
const renderCache = new Map<string, string>()
export function hasMermaid(text: string): boolean {
return /```mermaid/i.test(text)
}
let renderCounter = 0
export async function renderMermaidBlocks(html: string): Promise<string> {
await loadMermaid()
initMermaid()
const mermaid = mermaidModule!.default
// Find <pre><code class="language-mermaid">...</code></pre> blocks
const PRE_RE = /<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/gi
const matches = [...html.matchAll(PRE_RE)]
if (matches.length === 0) return html
let result = html
for (const match of matches) {
const raw = match[1]
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim()
const cacheKey = raw
if (renderCache.has(cacheKey)) {
result = result.replace(match[0], renderCache.get(cacheKey)!)
continue
}
try {
const id = `mermaid-${++renderCounter}`
const { svg } = await mermaid.render(id, raw)
const wrapped = `<div class="mermaid-diagram my-4 overflow-x-auto rounded-lg bg-white/5 border border-white/5 p-4">${svg}</div>`
renderCache.set(cacheKey, wrapped)
result = result.replace(match[0], wrapped)
} catch {
// Show error inline without crashing
const errHtml = `<div class="my-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-400/70">Mermaid render error</div><pre><code>${match[1]}</code></pre>`
result = result.replace(match[0], errHtml)
}
}
return result
}
@@ -0,0 +1,90 @@
import { ref } from 'vue'
import { storeApiKey, getApiKey, deleteApiKey } from '@/utils/key-vault'
const VAULT_PROVIDER = 'nwc'
export interface NWCConnection {
relayUrl: string
walletPubkey: string
secret: string
}
const isConnected = ref(false)
const balance = ref<number | null>(null)
const connectionString = ref<string | null>(null)
async function loadConnection(): Promise<NWCConnection | null> {
try {
const stored = await getApiKey(VAULT_PROVIDER)
if (stored) {
connectionString.value = stored
isConnected.value = true
return parseConnectionString(stored)
}
} catch { /* ignore */ }
return null
}
function parseConnectionString(str: string): NWCConnection | null {
// Format: nostr+walletconnect://pubkey?relay=wss://...&secret=hex
try {
const url = new URL(str)
const walletPubkey = url.hostname || url.pathname.replace('//', '')
const relayUrl = url.searchParams.get('relay') ?? ''
const secret = url.searchParams.get('secret') ?? ''
if (!walletPubkey || !relayUrl || !secret) return null
return { relayUrl, walletPubkey, secret }
} catch {
return null
}
}
export function useNWC() {
async function connect(nwcString: string): Promise<boolean> {
const parsed = parseConnectionString(nwcString)
if (!parsed) return false
await storeApiKey(VAULT_PROVIDER, nwcString)
connectionString.value = nwcString
isConnected.value = true
return true
}
async function disconnect() {
await deleteApiKey(VAULT_PROVIDER)
connectionString.value = null
isConnected.value = false
balance.value = null
}
async function getBalance(): Promise<number | null> {
const conn = await loadConnection()
if (!conn) return null
// NIP-47: Send get_balance request via relay
// This is a simplified version — full implementation needs NIP-47 event signing
return null
}
async function payInvoice(bolt11: string): Promise<{ success: boolean; preimage?: string; error?: string }> {
const conn = await loadConnection()
if (!conn) return { success: false, error: 'Not connected' }
// NIP-47: Send pay_invoice request via relay
// Full implementation requires NIP-47 event creation and signing with the secret
return { success: false, error: 'NWC pay requires NIP-47 event signing' }
}
// Load on init
loadConnection()
return {
isConnected,
balance,
connectionString,
connect,
disconnect,
getBalance,
payInvoice,
}
}
@@ -0,0 +1,76 @@
import { ref } from 'vue'
interface CacheEntry {
verified: boolean
pubkey: string
timestamp: number
}
const CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
const STORAGE_KEY = 'aiui-nip05-cache'
const verificationCache = ref<Map<string, CacheEntry>>(new Map())
function loadCache() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const entries = JSON.parse(stored) as [string, CacheEntry][]
const now = Date.now()
const valid = entries.filter(([, e]) => now - e.timestamp < CACHE_TTL)
verificationCache.value = new Map(valid)
}
} catch { /* ignore */ }
}
function saveCache() {
try {
const entries = Array.from(verificationCache.value.entries())
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
} catch { /* ignore */ }
}
loadCache()
export function useNip05Verification() {
async function verifyNip05(nip05: string, expectedPubkey: string): Promise<boolean> {
const cacheKey = `${nip05}:${expectedPubkey}`
const cached = verificationCache.value.get(cacheKey)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.verified
}
try {
const [name, domain] = nip05.split('@')
if (!name || !domain) return false
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`)
if (!res.ok) return cacheResult(cacheKey, false, expectedPubkey)
const data = await res.json()
const pubkey = data?.names?.[name]
const verified = pubkey === expectedPubkey
return cacheResult(cacheKey, verified, expectedPubkey)
} catch {
return cacheResult(cacheKey, false, expectedPubkey)
}
}
function cacheResult(key: string, verified: boolean, pubkey: string): boolean {
verificationCache.value.set(key, { verified, pubkey, timestamp: Date.now() })
saveCache()
return verified
}
function isVerified(nip05: string, pubkey: string): boolean | null {
const cached = verificationCache.value.get(`${nip05}:${pubkey}`)
if (!cached || Date.now() - cached.timestamp >= CACHE_TTL) return null
return cached.verified
}
return {
verifyNip05,
isVerified,
}
}
@@ -0,0 +1,550 @@
import { ref, shallowRef, onUnmounted } from 'vue'
export interface NostrEvent {
id: string
pubkey: string
kind: number
content: string
created_at: number
tags: string[][]
sig: string
}
export interface NostrNote {
id: string
pubkey: string
authorName?: string
authorPicture?: string
nip05?: string
kind: number
content: string
created_at: number
tags: string[][]
}
export interface RelayConfig {
url: string
read: boolean
write: boolean
}
export interface RelayInfo {
url: string
connected: boolean
read: boolean
write: boolean
latencyMs: number | null
}
interface RelayState {
url: string
ws: WebSocket | null
connected: boolean
read: boolean
write: boolean
latencyMs: number | null
connectTime: number | null
}
const DEFAULT_RELAYS: RelayConfig[] = [
{ url: 'wss://relay.damus.io', read: true, write: true },
{ url: 'wss://nos.lol', read: true, write: true },
{ url: 'wss://relay.snort.social', read: true, write: true },
]
const STORAGE_KEY = 'aiui-nostr-relays'
function loadRelayConfig(): RelayConfig[] {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) return JSON.parse(stored)
} catch { /* ignore */ }
return DEFAULT_RELAYS
}
function saveRelayConfig(configs: RelayConfig[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(configs))
}
const events = shallowRef<NostrNote[]>([])
const isConnected = ref(false)
const relayStates = ref<RelayInfo[]>([])
// Profile metadata cache: pubkey → { name, picture, nip05 }
interface ProfileMeta { name?: string; picture?: string; nip05?: string }
const profileCache = new Map<string, ProfileMeta>()
const pendingProfiles = new Set<string>()
let relays: RelayState[] = []
let subscriptionId: string | null = null
let profileSubId: string | null = null
let initialized = false
function generateSubId(): string {
return 'aiui-' + Math.random().toString(36).slice(2, 10)
}
function truncatePubkey(pubkey: string): string {
if (pubkey.length <= 12) return pubkey
return pubkey.slice(0, 8) + '...' + pubkey.slice(-4)
}
function parseEvent(data: unknown): NostrEvent | null {
if (!Array.isArray(data)) return null
if (data[0] !== 'EVENT' || !data[2]) return null
const evt = data[2]
if (!evt.id || !evt.pubkey || typeof evt.kind !== 'number' || typeof evt.content !== 'string') {
return null
}
return evt as NostrEvent
}
function handleMetadataEvent(evt: NostrEvent) {
if (evt.kind !== 0) return
try {
const meta = JSON.parse(evt.content) as Record<string, unknown>
const profile: ProfileMeta = {
name: (meta.display_name ?? meta.name ?? '') as string || undefined,
picture: (meta.picture ?? '') as string || undefined,
nip05: (meta.nip05 ?? '') as string || undefined,
}
profileCache.set(evt.pubkey, profile)
pendingProfiles.delete(evt.pubkey)
// Update existing notes with this profile data
const updated = events.value.map(n => {
if (n.pubkey !== evt.pubkey) return n
return {
...n,
authorName: profile.name || n.authorName,
authorPicture: profile.picture,
nip05: profile.nip05 || n.nip05,
}
})
events.value = updated
} catch { /* malformed kind 0 */ }
}
function enrichWithProfile(note: NostrNote): NostrNote {
const cached = profileCache.get(note.pubkey)
if (cached) {
return {
...note,
authorName: cached.name || note.authorName,
authorPicture: cached.picture,
nip05: cached.nip05 || note.nip05,
}
}
return note
}
function requestProfiles(pubkeys: string[]) {
const needed = pubkeys.filter(pk => !profileCache.has(pk) && !pendingProfiles.has(pk))
if (needed.length === 0) return
for (const pk of needed) pendingProfiles.add(pk)
// Send kind 0 REQ to connected relays
const subId = 'prof-' + Math.random().toString(36).slice(2, 8)
for (const relay of relays) {
if (relay.connected && relay.ws && relay.read) {
relay.ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: needed, limit: needed.length }]))
}
}
}
function addEvent(evt: NostrEvent) {
// Handle metadata events (kind 0) for profile pictures
if (evt.kind === 0) {
handleMetadataEvent(evt)
return
}
const existing = events.value.find(e => e.id === evt.id)
if (existing) return
const note = enrichWithProfile({
id: evt.id,
pubkey: evt.pubkey,
authorName: truncatePubkey(evt.pubkey),
kind: evt.kind,
content: evt.content,
created_at: evt.created_at,
tags: evt.tags ?? [],
})
const newEvents = [...events.value, note]
.sort((a, b) => b.created_at - a.created_at)
.slice(0, 200)
events.value = newEvents
// Queue profile fetch for this author
requestProfiles([evt.pubkey])
}
function connectRelay(relayState: RelayState) {
if (relayState.ws) return
try {
relayState.connectTime = Date.now()
const ws = new WebSocket(relayState.url)
relayState.ws = ws
ws.onopen = () => {
relayState.connected = true
relayState.latencyMs = relayState.connectTime ? Date.now() - relayState.connectTime : null
updateRelayStates()
isConnected.value = relays.some(r => r.connected)
if (relayState.read) {
if (!subscriptionId) subscriptionId = generateSubId()
ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [1], limit: 50 }]))
}
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
const evt = parseEvent(data)
if (evt) addEvent(evt)
} catch { /* skip */ }
}
ws.onclose = () => {
relayState.connected = false
relayState.ws = null
relayState.latencyMs = null
updateRelayStates()
isConnected.value = relays.some(r => r.connected)
}
ws.onerror = () => { /* triggers onclose */ }
} catch {
relayState.connected = false
updateRelayStates()
}
}
function updateRelayStates() {
relayStates.value = relays.map(r => ({
url: r.url,
connected: r.connected,
read: r.read,
write: r.write,
latencyMs: r.latencyMs,
}))
}
function connect() {
if (initialized) return
initialized = true
const configs = loadRelayConfig()
relays = configs.map(c => ({
url: c.url,
ws: null,
connected: false,
read: c.read,
write: c.write,
latencyMs: null,
connectTime: null,
}))
updateRelayStates()
relays.forEach(r => connectRelay(r))
}
function disconnect() {
if (subscriptionId) {
relays.forEach(r => {
if (r.ws && r.connected) {
try { r.ws.send(JSON.stringify(['CLOSE', subscriptionId])) } catch { /* ignore */ }
}
})
subscriptionId = null
}
relays.forEach(r => {
if (r.ws) {
r.ws.close()
r.ws = null
r.connected = false
}
})
updateRelayStates()
isConnected.value = false
initialized = false
}
function addRelay(url: string, read = true, write = true) {
if (relays.some(r => r.url === url)) return
const state: RelayState = { url, ws: null, connected: false, read, write, latencyMs: null, connectTime: null }
relays.push(state)
saveRelayConfig(relays.map(r => ({ url: r.url, read: r.read, write: r.write })))
updateRelayStates()
if (initialized) connectRelay(state)
}
function removeRelay(url: string) {
const idx = relays.findIndex(r => r.url === url)
if (idx === -1) return
const relay = relays[idx]
if (relay.ws) {
relay.ws.close()
relay.ws = null
}
relays.splice(idx, 1)
saveRelayConfig(relays.map(r => ({ url: r.url, read: r.read, write: r.write })))
updateRelayStates()
isConnected.value = relays.some(r => r.connected)
}
function toggleRelayRead(url: string) {
const relay = relays.find(r => r.url === url)
if (!relay) return
relay.read = !relay.read
saveRelayConfig(relays.map(r => ({ url: r.url, read: r.read, write: r.write })))
updateRelayStates()
}
function toggleRelayWrite(url: string) {
const relay = relays.find(r => r.url === url)
if (!relay) return
relay.write = !relay.write
saveRelayConfig(relays.map(r => ({ url: r.url, read: r.read, write: r.write })))
updateRelayStates()
}
function testRelay(url: string): Promise<number | null> {
return new Promise((resolve) => {
const start = Date.now()
const timer = setTimeout(() => resolve(null), 5000)
try {
const ws = new WebSocket(url)
ws.onopen = () => {
const latency = Date.now() - start
clearTimeout(timer)
ws.close()
resolve(latency)
}
ws.onerror = () => {
clearTimeout(timer)
resolve(null)
}
} catch {
clearTimeout(timer)
resolve(null)
}
})
}
function importNIP65Relays(event: NostrEvent) {
if (event.kind !== 10002) return
for (const tag of event.tags) {
if (tag[0] === 'r' && tag[1]) {
const url = tag[1]
const marker = tag[2]
const read = !marker || marker === 'read'
const write = !marker || marker === 'write'
addRelay(url, read, write)
}
}
}
const NIP50_RELAYS = [
'wss://relay.nostr.band',
'wss://nostr.wine',
]
const searchResults = shallowRef<NostrNote[]>([])
const isSearching = ref(false)
function searchNostr(query: string, kinds?: number[]): void {
if (!query.trim()) {
searchResults.value = []
return
}
isSearching.value = true
searchResults.value = []
const subId = generateSubId()
const filter: Record<string, unknown> = { search: query, limit: 50 }
if (kinds && kinds.length > 0) filter.kinds = kinds
const results: NostrNote[] = []
let closedCount = 0
for (const relayUrl of NIP50_RELAYS) {
try {
const ws = new WebSocket(relayUrl)
const timer = setTimeout(() => {
ws.close()
}, 8000)
ws.onopen = () => {
ws.send(JSON.stringify(['REQ', subId, filter]))
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
if (Array.isArray(data) && data[0] === 'EVENT' && data[1] === subId && data[2]) {
const evt = data[2] as NostrEvent
if (!results.find(r => r.id === evt.id)) {
results.push({
id: evt.id,
pubkey: evt.pubkey,
authorName: truncatePubkey(evt.pubkey),
kind: evt.kind,
content: evt.content,
created_at: evt.created_at,
tags: evt.tags ?? [],
})
searchResults.value = [...results].sort((a, b) => b.created_at - a.created_at)
}
}
if (Array.isArray(data) && data[0] === 'EOSE' && data[1] === subId) {
clearTimeout(timer)
ws.close()
}
} catch { /* skip */ }
}
ws.onclose = () => {
closedCount++
if (closedCount >= NIP50_RELAYS.length) {
isSearching.value = false
}
}
ws.onerror = () => {
clearTimeout(timer)
}
} catch {
closedCount++
if (closedCount >= NIP50_RELAYS.length) {
isSearching.value = false
}
}
}
}
export interface PublishResult {
url: string
success: boolean
message: string
}
function publishEvent(signedEvent: NostrEvent): Promise<PublishResult[]> {
const writeRelays = relays.filter(r => r.connected && r.ws && r.write)
if (writeRelays.length === 0) return Promise.resolve([])
const results = writeRelays.map(relay => {
return new Promise<PublishResult>((resolve) => {
const timer = setTimeout(() => {
resolve({ url: relay.url, success: false, message: 'Timeout' })
}, 5000)
const handler = (msg: MessageEvent) => {
try {
const data = JSON.parse(msg.data)
if (Array.isArray(data) && data[0] === 'OK' && data[1] === signedEvent.id) {
clearTimeout(timer)
relay.ws?.removeEventListener('message', handler)
resolve({
url: relay.url,
success: !!data[2],
message: data[3] ?? (data[2] ? 'Published' : 'Rejected'),
})
}
} catch { /* skip */ }
}
relay.ws!.addEventListener('message', handler)
try {
relay.ws!.send(JSON.stringify(['EVENT', signedEvent]))
} catch {
clearTimeout(timer)
relay.ws?.removeEventListener('message', handler)
resolve({ url: relay.url, success: false, message: 'Send failed' })
}
})
})
addEvent(signedEvent)
return Promise.all(results)
}
function fetchNote(hexId: string, timeoutMs = 5000): Promise<NostrNote | null> {
const cached = events.value.find(e => e.id === hexId)
if (cached) return Promise.resolve(cached)
return new Promise((resolve) => {
const subId = generateSubId()
let resolved = false
const timer = setTimeout(() => {
if (!resolved) { resolved = true; resolve(null) }
}, timeoutMs)
const relay = relays.find(r => r.connected && r.ws && r.read)
if (!relay?.ws) {
clearTimeout(timer)
resolve(null)
return
}
const handler = (msg: MessageEvent) => {
try {
const data = JSON.parse(msg.data)
if (Array.isArray(data) && data[0] === 'EVENT' && data[1] === subId && data[2]) {
const evt = data[2] as NostrEvent
const note: NostrNote = {
id: evt.id,
pubkey: evt.pubkey,
authorName: truncatePubkey(evt.pubkey),
kind: evt.kind,
content: evt.content,
created_at: evt.created_at,
tags: evt.tags ?? [],
}
if (!resolved) { resolved = true; clearTimeout(timer); resolve(note) }
relay.ws?.removeEventListener('message', handler)
}
if (Array.isArray(data) && data[0] === 'EOSE' && data[1] === subId) {
if (!resolved) { resolved = true; clearTimeout(timer); resolve(null) }
relay.ws?.removeEventListener('message', handler)
}
} catch { /* skip */ }
}
relay.ws.addEventListener('message', handler)
relay.ws.send(JSON.stringify(['REQ', subId, { ids: [hexId], limit: 1 }]))
})
}
export function useNostr() {
onUnmounted(() => {
disconnect()
})
return {
events,
isConnected,
relayStates,
connect,
disconnect,
fetchNote,
publishEvent,
searchResults,
isSearching,
searchNostr,
addRelay,
removeRelay,
toggleRelayRead,
toggleRelayWrite,
testRelay,
importNIP65Relays,
}
}
@@ -0,0 +1,214 @@
import { ref, computed } from 'vue'
import { useNostrIdentity } from './useNostrIdentity'
export interface DirectMessage {
id: string
fromPubkey: string
toPubkey: string
content: string
created_at: number
decrypted: boolean
}
export interface DMThread {
contactPubkey: string
contactName: string
messages: DirectMessage[]
lastMessage: DirectMessage | null
unread: number
}
const DB_NAME = 'aiui-nostr-dms'
const DB_VERSION = 1
const STORE_NAME = 'messages'
const threads = ref<DMThread[]>([])
const activeContact = ref<string | null>(null)
const isLoading = ref(false)
let dbPromise: Promise<IDBDatabase> | null = null
function openDMDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
store.createIndex('contact', 'contactPubkey', { unique: false })
store.createIndex('created_at', 'created_at', { unique: false })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => {
dbPromise = null
reject(req.error)
}
})
return dbPromise
}
interface StoredDM extends DirectMessage {
contactPubkey: string
}
function truncatePubkey(pk: string): string {
if (pk.length <= 12) return pk
return pk.slice(0, 8) + '...' + pk.slice(-4)
}
async function saveDM(msg: DirectMessage, myPubkey: string): Promise<void> {
const db = await openDMDB()
const contactPubkey = msg.fromPubkey === myPubkey ? msg.toPubkey : msg.fromPubkey
const record: StoredDM = { ...msg, contactPubkey }
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(record)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
async function loadAllDMs(): Promise<StoredDM[]> {
const db = await openDMDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).getAll()
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
function buildThreads(messages: StoredDM[]): DMThread[] {
const threadMap = new Map<string, DirectMessage[]>()
for (const msg of messages) {
const existing = threadMap.get(msg.contactPubkey) ?? []
existing.push(msg)
threadMap.set(msg.contactPubkey, existing)
}
const result: DMThread[] = []
for (const [contactPubkey, msgs] of threadMap) {
msgs.sort((a, b) => a.created_at - b.created_at)
result.push({
contactPubkey,
contactName: truncatePubkey(contactPubkey),
messages: msgs,
lastMessage: msgs[msgs.length - 1] ?? null,
unread: 0,
})
}
// Sort by latest message
result.sort((a, b) => (b.lastMessage?.created_at ?? 0) - (a.lastMessage?.created_at ?? 0))
return result
}
export function useNostrDMs() {
const { pubkey, isLoggedIn } = useNostrIdentity()
const activeThread = computed(() => {
if (!activeContact.value) return null
return threads.value.find(t => t.contactPubkey === activeContact.value) ?? null
})
async function loadDMs() {
if (!isLoggedIn.value) return
isLoading.value = true
try {
const all = await loadAllDMs()
threads.value = buildThreads(all)
} catch {
// IDB error
} finally {
isLoading.value = false
}
}
async function sendDM(toPubkey: string, plaintext: string): Promise<boolean> {
if (!window.nostr?.nip04 || !pubkey.value) return false
try {
const encrypted = await window.nostr.nip04.encrypt(toPubkey, plaintext)
const unsigned = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', toPubkey]],
content: encrypted,
}
const signed = await window.nostr.signEvent(unsigned)
if (!signed) return false
// Store locally
const dm: DirectMessage = {
id: signed.id,
fromPubkey: pubkey.value,
toPubkey,
content: plaintext,
created_at: signed.created_at,
decrypted: true,
}
await saveDM(dm, pubkey.value)
// Broadcast to relays (reuse useNostr relay infra)
const { publishEvent } = await import('./useNostr').then(m => m.useNostr())
await publishEvent(signed)
// Refresh threads
const all = await loadAllDMs()
threads.value = buildThreads(all)
return true
} catch {
return false
}
}
async function receiveDM(eventId: string, fromPubkey: string, encryptedContent: string, createdAt: number): Promise<void> {
if (!window.nostr?.nip04 || !pubkey.value) return
try {
const plaintext = await window.nostr.nip04.decrypt(fromPubkey, encryptedContent)
const dm: DirectMessage = {
id: eventId,
fromPubkey,
toPubkey: pubkey.value,
content: plaintext,
created_at: createdAt,
decrypted: true,
}
await saveDM(dm, pubkey.value)
const all = await loadAllDMs()
threads.value = buildThreads(all)
} catch {
// Decryption failed
}
}
function selectContact(contactPubkey: string) {
activeContact.value = contactPubkey
}
function clearActiveContact() {
activeContact.value = null
}
return {
threads,
activeThread,
activeContact,
isLoading,
loadDMs,
sendDM,
receiveDM,
selectContact,
clearActiveContact,
}
}
@@ -0,0 +1,119 @@
import { ref, computed, onMounted } from 'vue'
import { encodeNpub } from '@/utils/bech32'
/**
* NIP-07 browser extension interface (nos2x, Alby, etc.)
*/
interface NostrExtension {
getPublicKey(): Promise<string>
signEvent(event: UnsignedNostrEvent): Promise<SignedNostrEvent>
getRelays?(): Promise<Record<string, { read: boolean; write: boolean }>>
nip04?: {
encrypt(pubkey: string, plaintext: string): Promise<string>
decrypt(pubkey: string, ciphertext: string): Promise<string>
}
}
export interface UnsignedNostrEvent {
kind: number
created_at: number
tags: string[][]
content: string
}
export interface SignedNostrEvent extends UnsignedNostrEvent {
id: string
pubkey: string
sig: string
}
declare global {
interface Window {
nostr?: NostrExtension
}
}
const pubkey = ref<string | null>(null)
const isAvailable = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
export function useNostrIdentity() {
const npub = computed(() => {
if (!pubkey.value) return null
try {
return encodeNpub(pubkey.value)
} catch {
return null
}
})
const isLoggedIn = computed(() => !!pubkey.value)
const truncatedNpub = computed(() => {
if (!npub.value) return null
return npub.value.slice(0, 12) + '...' + npub.value.slice(-8)
})
function checkAvailability() {
isAvailable.value = typeof window !== 'undefined' && !!window.nostr
}
async function login(): Promise<void> {
error.value = null
if (!window.nostr) {
error.value = 'No Nostr extension detected. Install nos2x, Alby, or another NIP-07 extension.'
return
}
isLoading.value = true
try {
const pk = await window.nostr.getPublicKey()
pubkey.value = pk
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to get public key'
} finally {
isLoading.value = false
}
}
async function signEvent(event: UnsignedNostrEvent): Promise<SignedNostrEvent | null> {
error.value = null
if (!window.nostr) {
error.value = 'No Nostr extension detected'
return null
}
try {
return await window.nostr.signEvent(event)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to sign event'
return null
}
}
function logout() {
pubkey.value = null
error.value = null
}
onMounted(() => {
checkAvailability()
// Some extensions load async — recheck after a short delay
setTimeout(checkAvailability, 500)
})
return {
pubkey,
npub,
isAvailable,
isLoggedIn,
isLoading,
error,
truncatedNpub,
login,
logout,
signEvent,
checkAvailability,
}
}
@@ -0,0 +1,75 @@
import { ref, onMounted, onUnmounted } from 'vue'
export interface SyncAction {
type: string
payload: unknown
timestamp: number
}
const isOnline = ref(navigator.onLine)
const pendingSync = ref(0)
const syncQueue: SyncAction[] = []
function handleOnline() {
isOnline.value = true
processSyncQueue()
}
function handleOffline() {
isOnline.value = false
}
/** Queue an action to be synced when back online */
function queueForSync(action: SyncAction) {
syncQueue.push(action)
pendingSync.value = syncQueue.length
}
/** Process all pending sync actions */
async function processSyncQueue(): Promise<void> {
if (!isOnline.value || syncQueue.length === 0) return
while (syncQueue.length > 0) {
const action = syncQueue.shift()
if (!action) break
try {
// Route sync actions based on type
if (action.type === 'conversation') {
// Conversation sync handled by idb-storage auto-save
continue
}
if (action.type === 'favorite') {
// Favorites are local-only, no sync needed
continue
}
// Add more sync handlers as needed
} catch {
// Re-queue failed actions
syncQueue.unshift(action)
break
}
}
pendingSync.value = syncQueue.length
}
export function useOffline() {
onMounted(() => {
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
isOnline.value = navigator.onLine
})
onUnmounted(() => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
})
return {
isOnline,
pendingSync,
queueForSync,
processSyncQueue,
}
}
@@ -0,0 +1,73 @@
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
export function usePinchZoom(elementRef: Ref<HTMLElement | null>) {
const scale = ref(1)
const translateX = ref(0)
const translateY = ref(0)
let initialDistance = 0
let initialScale = 1
let lastTapTime = 0
function getDistance(t1: Touch, t2: Touch): number {
return Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY)
}
function onTouchStart(e: TouchEvent) {
if (e.touches.length === 2) {
e.preventDefault()
initialDistance = getDistance(e.touches[0], e.touches[1])
initialScale = scale.value
} else if (e.touches.length === 1) {
// Double-tap detection
const now = Date.now()
if (now - lastTapTime < 300) {
// Double-tap: reset
scale.value = 1
translateX.value = 0
translateY.value = 0
}
lastTapTime = now
}
}
function onTouchMove(e: TouchEvent) {
if (e.touches.length === 2) {
e.preventDefault()
const currentDistance = getDistance(e.touches[0], e.touches[1])
const newScale = initialScale * (currentDistance / initialDistance)
scale.value = Math.max(1, Math.min(4, newScale))
}
}
function onTouchEnd() {
if (scale.value <= 1.05) {
scale.value = 1
translateX.value = 0
translateY.value = 0
}
}
onMounted(() => {
const el = elementRef.value
if (!el) return
el.addEventListener('touchstart', onTouchStart, { passive: false })
el.addEventListener('touchmove', onTouchMove, { passive: false })
el.addEventListener('touchend', onTouchEnd)
})
onUnmounted(() => {
const el = elementRef.value
if (!el) return
el.removeEventListener('touchstart', onTouchStart)
el.removeEventListener('touchmove', onTouchMove)
el.removeEventListener('touchend', onTouchEnd)
})
return {
scale,
translateX,
translateY,
transform: () => `scale(${scale.value}) translate(${translateX.value}px, ${translateY.value}px)`,
}
}
@@ -0,0 +1,433 @@
import { ref, shallowRef, computed, watch } from 'vue'
import type { Song } from '@aiui/core/types/content'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
import { apiFetch } from '@/utils/api-fetch'
interface MusicSearchResult {
source: 'wavlake' | 'node'
type: 'stream'
url: string
title?: string
artist?: string
coverUrl?: string
duration?: number
trackId?: string
albumTitle?: string
wavlakeUrl?: string
}
// ─── Global singleton state ───────────────────────────────────
const currentSong = ref<Song | null>(null)
const playableSource = ref<MusicSearchResult | null>(null)
const isPlaying = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
const currentTime = ref(0)
const duration = ref(0)
// Queue management
const queue = shallowRef<Song[]>([])
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
// Null results use a short TTL so transient failures don't stick
const NULL_CACHE_TTL = 2 * 60 * 1000 // 2 minutes
const resultCache = new Map<string, MusicSearchResult | null>()
const nullCacheTimestamps = new Map<string, number>()
// Active search abort controller — cancel stale searches on rapid switching
let activeSearchController: AbortController | null = null
export function usePlayer() {
const hasTrack = computed(() => !!currentSong.value)
const progress = computed(() => {
if (duration.value <= 0) return 0
return (currentTime.value / duration.value) * 100
})
// ─── Search with abort + cache ────────────────────────────
async function searchWavlakeDirect(
query: string,
title?: string,
artist?: string,
signal?: AbortSignal,
): Promise<MusicSearchResult | null> {
const searches: string[] = []
if (title) searches.push(title)
if (title && artist) searches.push(`${title} ${artist}`)
if (artist) searches.push(artist)
if (!title && !artist) searches.push(query)
for (const term of searches) {
try {
const res = await fetch(
`https://wavlake.com/api/v1/content/search?term=${encodeURIComponent(term)}`,
{ signal, headers: { Accept: 'application/json' } },
)
if (!res.ok) continue
const items = (await res.json()) as {
id: string; title?: string; name?: string; type: string
mediaUrl?: string; artist?: string; albumArtUrl?: string
artistArtUrl?: string; duration?: number; albumTitle?: string
}[]
if (!Array.isArray(items)) continue
const tracks = items.filter(i => i.type === 'track' && !!i.mediaUrl)
if (tracks.length === 0) continue
const best = tracks[0]
return {
source: 'wavlake',
type: 'stream',
url: best.mediaUrl!,
title: best.title ?? best.name,
artist: best.artist,
coverUrl: best.albumArtUrl ?? best.artistArtUrl,
duration: best.duration,
trackId: best.id,
albumTitle: best.albumTitle,
wavlakeUrl: `https://wavlake.com/track/${best.id}`,
}
} catch (e) {
if ((e as Error).name === 'AbortError') return null
}
}
return null
}
async function searchMusic(query: string, title?: string, artist?: string): Promise<MusicSearchResult | null> {
const cacheKey = `${title ?? query}|${artist ?? ''}`
const cached = resultCache.get(cacheKey)
if (cached !== undefined) {
// Positive results stay cached forever; null results expire after TTL
if (cached !== null) return cached
const ts = nullCacheTimestamps.get(cacheKey)
if (ts && Date.now() - ts < NULL_CACHE_TTL) return null
// Expired null — retry
resultCache.delete(cacheKey)
nullCacheTimestamps.delete(cacheKey)
}
// Cancel any in-flight search
activeSearchController?.abort()
const controller = new AbortController()
activeSearchController = controller
// Try local API proxy first (works in dev), then Wavlake directly (works in prod)
let result: MusicSearchResult | null = null
try {
const params = new URLSearchParams({ q: query })
if (title) params.set('title', title)
if (artist) params.set('artist', artist)
const base = import.meta.env.BASE_URL || '/'
const res = await apiFetch(`${base}api/music/search?${params}`, {
signal: controller.signal,
})
if (res.ok) {
const data = (await res.json()) as MusicSearchResult & { error?: string }
if (!data.error && data.url) {
result = data as MusicSearchResult
}
}
} catch (e) {
if ((e as Error).name === 'AbortError') return null
}
// Fallback: call Wavlake API directly
if (!result) {
result = await searchWavlakeDirect(query, title, artist, controller.signal)
}
if (!result) {
error.value = 'Not found on Wavlake'
resultCache.set(cacheKey, null)
nullCacheTimestamps.set(cacheKey, Date.now())
return null
}
resultCache.set(cacheKey, result)
return result
}
// ─── Container management ─────────────────────────────────
function setContainer(el: HTMLDivElement | null) {
containerEl = el
if (el && playableSource.value) {
initPlayer(playableSource.value)
}
}
// ─── Audio player init — reuses audio element when possible ─
function initPlayer(result: MusicSearchResult) {
if (!containerEl) return
// 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.preload = 'auto'
containerEl.textContent = ''
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
})
plyrInstance.on('error', (event: Plyr.PlyrEvent) => {
const mediaError = audioEl?.error
console.error('[player] Audio error:', mediaError?.code, mediaError?.message)
isLoading.value = false
error.value = 'Audio failed to load'
})
}
function destroyPlayer() {
if (plyrInstance) {
plyrInstance.destroy()
plyrInstance = null
}
audioEl = null
if (containerEl) {
containerEl.textContent = ''
}
}
// ─── 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) {
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
// A real node track carries its own sources (same-origin `/content/<id>`,
// Range-streamed by the node itself) — play those FIRST. Wavlake is the
// metadata-only fallback; on a node it is CSP-blocked outright, so every
// library track used to end at "Not found on Wavlake" without ever
// trying the bytes sitting on the operator's own disk.
const nodeSource = song.sources?.find(
(s) => !!s.url && (s.url.startsWith('/') || s.url.startsWith(window.location.origin)),
)
if (nodeSource) {
isLoading.value = false
error.value = null
playableSource.value = {
source: 'node',
type: 'stream',
url: nodeSource.url,
title: song.title,
artist: song.artist,
coverUrl: song.coverUrl,
duration: song.duration,
}
if (containerEl) {
initPlayer(playableSource.value)
}
return
}
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
if (!result) {
if (!error.value) error.value = 'Not found on Wavlake'
return
}
// Enrich song with Wavlake metadata if available
if (result.coverUrl && currentSong.value && !currentSong.value.coverUrl) {
currentSong.value = { ...currentSong.value, coverUrl: result.coverUrl }
}
if (result.duration && currentSong.value && !currentSong.value.duration) {
currentSong.value = { ...currentSong.value, duration: result.duration }
}
error.value = null
playableSource.value = result
if (containerEl) {
initPlayer(result)
}
}
function pause() {
plyrInstance?.pause()
isPlaying.value = false
}
function toggle() {
if (isLoading.value) return
if (plyrInstance) {
if (isPlaying.value) pause()
else Promise.resolve(plyrInstance.play()).catch(() => {})
}
}
function seek(percent: number) {
if (!plyrInstance || !duration.value) return
const time = (percent / 100) * duration.value
plyrInstance.currentTime = time
currentTime.value = time
}
// ─── Queue management ─────────────────────────────────────
function addToQueue(song: Song) {
const existing = queue.value.find(s => s.id === song.id)
if (!existing) {
queue.value = [...queue.value, song]
}
}
function playNext() {
if (queue.value.length === 0) return
const nextIdx = currentIndex.value + 1
if (nextIdx < queue.value.length) {
currentIndex.value = nextIdx
play(queue.value[nextIdx])
}
}
function playPrevious() {
if (queue.value.length === 0) return
const prevIdx = currentIndex.value - 1
if (prevIdx >= 0) {
currentIndex.value = prevIdx
play(queue.value[prevIdx])
}
}
function removeFromQueue(index: number) {
const newQueue = [...queue.value]
newQueue.splice(index, 1)
queue.value = newQueue
if (index < currentIndex.value) {
currentIndex.value--
} else if (index === currentIndex.value) {
currentIndex.value = Math.min(currentIndex.value, newQueue.length - 1)
}
}
async function playWithQueue(song: Song) {
const idx = queue.value.findIndex(s => s.id === song.id)
if (idx === -1) {
addToQueue(song)
currentIndex.value = queue.value.length - 1
} else {
currentIndex.value = idx
}
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()
currentSong.value = null
playableSource.value = null
currentTime.value = 0
duration.value = 0
error.value = null
}
function clearQueue() {
clear()
queue.value = []
currentIndex.value = -1
}
const hasNext = computed(() => currentIndex.value < queue.value.length - 1)
const hasPrevious = computed(() => currentIndex.value > 0)
return {
currentSong,
playableSource,
isPlaying,
isLoading,
error,
currentTime,
duration,
hasTrack,
progress,
queue,
currentIndex,
hasNext,
hasPrevious,
play: playWithQueue,
pause,
toggle,
seek,
clear,
clearQueue,
addToQueue,
playNext,
playPrevious,
removeFromQueue,
setContainer,
}
}
@@ -0,0 +1,32 @@
const prefetchCache = new Map<string, { data: unknown; expiresAt: number }>()
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
export function usePrefetch() {
function getCached<T>(key: string): T | null {
const entry = prefetchCache.get(key)
if (!entry) return null
if (Date.now() > entry.expiresAt) {
prefetchCache.delete(key)
return null
}
return entry.data as T
}
function setCached(key: string, data: unknown) {
prefetchCache.set(key, { data, expiresAt: Date.now() + CACHE_DURATION })
}
async function prefetch(key: string, fetcher: () => Promise<unknown>) {
if (getCached(key)) return
try {
const data = await fetcher()
setCached(key, data)
} catch { /* ignore prefetch errors */ }
}
function clearCache() {
prefetchCache.clear()
}
return { getCached, setCached, prefetch, clearCache }
}
@@ -0,0 +1,68 @@
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
import { useHaptics } from './useHaptics'
export function usePullToRefresh(
elementRef: Ref<HTMLElement | null>,
onRefresh: () => Promise<void> | void
) {
const isPulling = ref(false)
const isRefreshing = ref(false)
const pullDistance = ref(0)
const haptics = useHaptics()
const TRIGGER_DISTANCE = 80
let startY = 0
let tracking = false
function onTouchStart(e: TouchEvent) {
const el = elementRef.value
if (!el || el.scrollTop > 0) return
startY = e.touches[0].clientY
tracking = true
}
function onTouchMove(e: TouchEvent) {
if (!tracking || isRefreshing.value) return
const dy = e.touches[0].clientY - startY
if (dy < 0) {
tracking = false
return
}
pullDistance.value = Math.min(dy * 0.5, 120)
isPulling.value = pullDistance.value > 20
}
async function onTouchEnd() {
if (!tracking) return
tracking = false
if (pullDistance.value >= TRIGGER_DISTANCE) {
haptics.pullRefresh()
isRefreshing.value = true
await onRefresh()
isRefreshing.value = false
}
isPulling.value = false
pullDistance.value = 0
}
onMounted(() => {
const el = elementRef.value
if (!el) return
el.addEventListener('touchstart', onTouchStart, { passive: true })
el.addEventListener('touchmove', onTouchMove, { passive: true })
el.addEventListener('touchend', onTouchEnd)
})
onUnmounted(() => {
const el = elementRef.value
if (!el) return
el.removeEventListener('touchstart', onTouchStart)
el.removeEventListener('touchmove', onTouchMove)
el.removeEventListener('touchend', onTouchEnd)
})
return { isPulling, isRefreshing, pullDistance }
}
@@ -0,0 +1,69 @@
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
export function useRovingTabindex(containerRef: Ref<HTMLElement | null>, selector = '[role="listitem"], [data-roving]') {
const currentIndex = ref(0)
function getItems(): HTMLElement[] {
const container = containerRef.value
if (!container) return []
return Array.from(container.querySelectorAll<HTMLElement>(selector))
}
function updateTabindex() {
const items = getItems()
items.forEach((item, i) => {
item.setAttribute('tabindex', i === currentIndex.value ? '0' : '-1')
})
}
function onKeyDown(e: KeyboardEvent) {
const items = getItems()
if (items.length === 0) return
let handled = false
switch (e.key) {
case 'ArrowDown':
case 'ArrowRight':
currentIndex.value = (currentIndex.value + 1) % items.length
handled = true
break
case 'ArrowUp':
case 'ArrowLeft':
currentIndex.value = (currentIndex.value - 1 + items.length) % items.length
handled = true
break
case 'Home':
currentIndex.value = 0
handled = true
break
case 'End':
currentIndex.value = items.length - 1
handled = true
break
}
if (handled) {
e.preventDefault()
updateTabindex()
items[currentIndex.value]?.focus()
}
}
onMounted(() => {
const container = containerRef.value
if (container) {
container.addEventListener('keydown', onKeyDown)
updateTabindex()
}
})
onUnmounted(() => {
const container = containerRef.value
if (container) {
container.removeEventListener('keydown', onKeyDown)
}
})
return { currentIndex, updateTabindex }
}
@@ -0,0 +1,27 @@
import type { WebSearchResult } from '@aiui/core/types/message'
import { apiFetch } from '@/utils/api-fetch'
export async function fetchRssFromUrls(urls: string[]): Promise<WebSearchResult[]> {
const safe = urls.filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u.trim())).slice(0, 8)
if (safe.length === 0) return []
try {
const params = new URLSearchParams()
safe.forEach((u) => params.append('url', u))
const res = await apiFetch(`/api/rss-articles?${params}`, { signal: AbortSignal.timeout(15000) })
if (!res.ok) return []
const data = (await res.json()) as { articles?: Array<{ title?: string; url?: string; content?: string; imgSrc?: string }> }
const articles = data.articles ?? []
return articles
.filter((a) => a.title && a.url)
.map((a) => ({
title: a.title ?? '',
url: a.url ?? '',
content: a.content,
imgSrc: a.imgSrc,
}))
} catch (err) {
console.warn('[AIUI rss]', err)
return []
}
}
@@ -0,0 +1,41 @@
import { onMounted, onUnmounted, type Ref } from 'vue'
const scrollPositions = new Map<string, number>()
export function useScrollMemory(key: string, elementRef: Ref<HTMLElement | null>) {
function savePosition() {
const el = elementRef.value
if (el) {
scrollPositions.set(key, el.scrollTop)
}
}
function restorePosition() {
const el = elementRef.value
if (!el) return
const saved = scrollPositions.get(key)
if (saved !== undefined) {
requestAnimationFrame(() => {
el.scrollTop = saved
})
}
}
onMounted(() => {
restorePosition()
const el = elementRef.value
if (el) {
el.addEventListener('scroll', savePosition, { passive: true })
}
})
onUnmounted(() => {
savePosition()
const el = elementRef.value
if (el) {
el.removeEventListener('scroll', savePosition)
}
})
return { savePosition, restorePosition }
}
@@ -0,0 +1,110 @@
import { ref } from 'vue'
import type { FavoriteType } from '@/stores/favorites'
import { getApiKey } from '@/utils/key-vault'
import { apiFetch } from '@/utils/api-fetch'
const CACHE_KEY = 'aiui-similar-content'
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000 // 7 days
interface SimilarItem {
title: string
type: FavoriteType
reason: string
}
interface CachedSuggestion {
itemId: string
suggestions: SimilarItem[]
cachedAt: number
}
function loadCache(): CachedSuggestion[] {
try {
const stored = localStorage.getItem(CACHE_KEY)
if (stored) return JSON.parse(stored)
} catch { /* ignore */ }
return []
}
function saveCache(cache: CachedSuggestion[]) {
localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
}
export function useSimilarContent() {
const suggestions = ref<SimilarItem[]>([])
const isLoading = ref(false)
async function fetchSimilar(itemId: string, title: string, type: FavoriteType) {
// Check cache first
const cache = loadCache()
const now = Date.now()
const cached = cache.find(c => c.itemId === itemId && (now - c.cachedAt) < CACHE_DURATION)
if (cached) {
suggestions.value = cached.suggestions
return
}
isLoading.value = true
suggestions.value = []
try {
const apiKey = await getApiKey('claude')
if (!apiKey) {
isLoading.value = false
return
}
const typeLabel = type === 'tv' ? 'TV series' : type
const prompt = `List exactly 3 ${typeLabel}s similar to "${title}". For each, respond with ONLY a JSON array like: [{"title":"Name","reason":"one sentence why"}]. No other text.`
const base = import.meta.env.BASE_URL || '/'
const res = await apiFetch(`${base}api/claude/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
body: JSON.stringify({
model: 'claude-haiku-4-5-20251001',
max_tokens: 300,
messages: [{ role: 'user', content: prompt }],
}),
})
if (!res.ok) {
isLoading.value = false
return
}
const data = await res.json()
const text = data.content?.[0]?.text ?? ''
// Extract JSON array from response
const match = text.match(/\[[\s\S]*\]/)
if (match) {
let parsed: { title: string; reason: string }[]
try {
parsed = JSON.parse(match[0]) as { title: string; reason: string }[]
} catch { return }
const items: SimilarItem[] = parsed.slice(0, 3).map(p => ({
title: p.title,
type,
reason: p.reason,
}))
suggestions.value = items
// Cache result
const updatedCache = cache.filter(c => c.itemId !== itemId)
updatedCache.push({ itemId, suggestions: items, cachedAt: now })
// Keep cache small
if (updatedCache.length > 100) updatedCache.splice(0, updatedCache.length - 100)
saveCache(updatedCache)
}
} catch { /* ignore errors */ } finally {
isLoading.value = false
}
}
return {
suggestions,
isLoading,
fetchSimilar,
}
}
@@ -0,0 +1,77 @@
import { ref, onMounted, onUnmounted } from 'vue'
export function useSwipeNavigation(options: {
onSwipeLeft?: () => void
onSwipeRight?: () => void
threshold?: number
velocityThreshold?: number
}) {
const { threshold = 80, velocityThreshold = 0.3 } = options
const isSwiping = ref(false)
const swipeOffset = ref(0)
let startX = 0
let startY = 0
let startTime = 0
let tracking = false
function onTouchStart(e: TouchEvent) {
startX = e.touches[0].clientX
startY = e.touches[0].clientY
startTime = Date.now()
tracking = true
swipeOffset.value = 0
}
function onTouchMove(e: TouchEvent) {
if (!tracking) return
const dx = e.touches[0].clientX - startX
const dy = e.touches[0].clientY - startY
// Only track horizontal swipes
if (Math.abs(dy) > Math.abs(dx)) {
tracking = false
return
}
swipeOffset.value = dx
isSwiping.value = Math.abs(dx) > 20
}
function onTouchEnd() {
if (!tracking) {
isSwiping.value = false
swipeOffset.value = 0
return
}
const elapsed = Date.now() - startTime
const velocity = Math.abs(swipeOffset.value) / elapsed
if (Math.abs(swipeOffset.value) > threshold || velocity > velocityThreshold) {
if (swipeOffset.value > 0) {
options.onSwipeRight?.()
} else {
options.onSwipeLeft?.()
}
}
tracking = false
isSwiping.value = false
swipeOffset.value = 0
}
onMounted(() => {
document.addEventListener('touchstart', onTouchStart, { passive: true })
document.addEventListener('touchmove', onTouchMove, { passive: true })
document.addEventListener('touchend', onTouchEnd)
})
onUnmounted(() => {
document.removeEventListener('touchstart', onTouchStart)
document.removeEventListener('touchmove', onTouchMove)
document.removeEventListener('touchend', onTouchEnd)
})
return { isSwiping, swipeOffset }
}
@@ -0,0 +1,90 @@
import { ref, onMounted, onUnmounted } from 'vue'
const STORAGE_KEY = 'aiui-sync-queue'
interface QueuedOperation {
id: string
type: string
data: unknown
createdAt: number
}
const queue = ref<QueuedOperation[]>([])
const hasQueuedItems = ref(false)
function loadQueue() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) queue.value = JSON.parse(stored)
hasQueuedItems.value = queue.value.length > 0
} catch { /* ignore */ }
}
function saveQueue() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(queue.value))
hasQueuedItems.value = queue.value.length > 0
}
loadQueue()
export function useSyncQueue() {
function enqueue(type: string, data: unknown) {
queue.value.push({
id: crypto.randomUUID(),
type,
data,
createdAt: Date.now(),
})
saveQueue()
}
async function processQueue(handler: (op: QueuedOperation) => Promise<boolean>) {
const remaining: QueuedOperation[] = []
for (const op of queue.value) {
try {
const success = await handler(op)
if (!success) remaining.push(op)
} catch {
remaining.push(op)
}
}
queue.value = remaining
saveQueue()
}
function clearQueue() {
queue.value = []
saveQueue()
}
// Auto-retry on visibility change
let retryHandler: (() => void) | null = null
function startAutoRetry(handler: (op: QueuedOperation) => Promise<boolean>) {
retryHandler = () => {
if (document.visibilityState === 'visible' && queue.value.length > 0) {
processQueue(handler)
}
}
document.addEventListener('visibilitychange', retryHandler)
}
onMounted(() => {
// Already loaded
})
onUnmounted(() => {
if (retryHandler) {
document.removeEventListener('visibilitychange', retryHandler)
}
})
return {
queue,
hasQueuedItems,
enqueue,
processQueue,
clearQueue,
startAutoRetry,
}
}
@@ -0,0 +1,49 @@
/**
* Extract markdown tables from text into structured data
* for interactive table rendering.
*/
export interface TableData {
headers: string[]
rows: string[][]
raw: string
}
// Matches a markdown table: header row, separator row, data rows
const TABLE_RE = /^(\|[^\n]+\|)\n(\|[\s:|-]+\|)\n((?:\|[^\n]+\|\n?)+)/gm
export function extractTables(text: string): TableData[] {
const results: TableData[] = []
let m: RegExpExecArray | null
const re = new RegExp(TABLE_RE.source, TABLE_RE.flags)
while ((m = re.exec(text)) !== null) {
const headerLine = m[1]
const dataBlock = m[3]
const headers = parseRow(headerLine)
const rows = dataBlock
.trim()
.split('\n')
.map(parseRow)
.filter((r) => r.length > 0)
if (headers.length > 0 && rows.length > 0) {
results.push({ headers, rows, raw: m[0] })
}
}
return results
}
function parseRow(line: string): string[] {
return line
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
}
export function hasTables(text: string): boolean {
return TABLE_RE.test(text)
}
@@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useTheme } from './useTheme'
describe('useTheme', () => {
beforeEach(() => {
localStorage.clear()
document.documentElement.classList.remove('dark', 'light')
// Reset module-level state by setting to default
const { setTheme } = useTheme()
setTheme('dark')
vi.restoreAllMocks()
})
it('returns expected API', () => {
const theme = useTheme()
expect(theme.currentTheme).toBeDefined()
expect(theme.isDark).toBeDefined()
expect(theme.setTheme).toBeTypeOf('function')
expect(theme.toggleTheme).toBeTypeOf('function')
expect(theme.initTheme).toBeTypeOf('function')
})
it('defaults to dark theme', () => {
const { isDark, currentTheme } = useTheme()
expect(currentTheme.value).toBe('dark')
expect(isDark.value).toBe(true)
})
it('setTheme switches to light', () => {
const { setTheme, isDark, currentTheme } = useTheme()
setTheme('light')
expect(currentTheme.value).toBe('light')
expect(isDark.value).toBe(false)
expect(localStorage.getItem('aiui-theme')).toBe('light')
expect(document.documentElement.classList.contains('light')).toBe(true)
expect(document.documentElement.classList.contains('dark')).toBe(false)
})
it('setTheme switches to dark', () => {
const { setTheme } = useTheme()
setTheme('light')
setTheme('dark')
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(document.documentElement.classList.contains('light')).toBe(false)
expect(localStorage.getItem('aiui-theme')).toBe('dark')
})
it('toggleTheme flips between dark and light', () => {
const { toggleTheme, currentTheme } = useTheme()
expect(currentTheme.value).toBe('dark')
toggleTheme()
expect(currentTheme.value).toBe('light')
toggleTheme()
expect(currentTheme.value).toBe('dark')
})
it('initTheme restores saved preference from localStorage', () => {
localStorage.setItem('aiui-theme', 'light')
const { initTheme, currentTheme } = useTheme()
initTheme()
expect(currentTheme.value).toBe('light')
})
it('initTheme falls back to prefers-color-scheme when no saved preference', () => {
localStorage.clear()
const matchMediaSpy = vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: false,
} as MediaQueryList)
const { initTheme, currentTheme } = useTheme()
initTheme()
expect(matchMediaSpy).toHaveBeenCalledWith('(prefers-color-scheme: dark)')
expect(currentTheme.value).toBe('light')
})
it('initTheme uses dark when system prefers dark', () => {
localStorage.clear()
vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,
} as MediaQueryList)
const { initTheme, currentTheme } = useTheme()
initTheme()
expect(currentTheme.value).toBe('dark')
})
it('shares state across multiple useTheme calls', () => {
const theme1 = useTheme()
const theme2 = useTheme()
theme1.setTheme('light')
expect(theme2.currentTheme.value).toBe('light')
expect(theme2.isDark.value).toBe(false)
})
})
@@ -0,0 +1,33 @@
import { ref, computed } from 'vue'
type ThemeName = 'dark' | 'light'
const currentTheme = ref<ThemeName>('dark')
export function useTheme() {
const isDark = computed(() => currentTheme.value === 'dark')
const setTheme = (theme: ThemeName) => {
currentTheme.value = theme
localStorage.setItem('aiui-theme', theme)
document.documentElement.classList.toggle('dark', theme === 'dark')
document.documentElement.classList.toggle('light', theme === 'light')
}
const toggleTheme = () => {
setTheme(isDark.value ? 'light' : 'dark')
}
const initTheme = () => {
const saved = localStorage.getItem('aiui-theme') as ThemeName | null
if (saved) {
setTheme(saved)
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('dark')
} else {
setTheme('light')
}
}
return { currentTheme, isDark, setTheme, toggleTheme, initTheme }
}
@@ -0,0 +1,64 @@
import { ref, onMounted, onUnmounted } from 'vue'
/**
* Tracks the visual viewport to detect mobile keyboard open/close.
* Uses the VisualViewport API to compute keyboard height as the difference
* between window.innerHeight and visualViewport.height.
*
* When the keyboard opens, viewportHeight shrinks to the visible area above
* the keyboard. Bind your container's height to viewportHeight to make the
* layout resize instead of the browser pushing content offscreen.
*/
export function useVisualViewport() {
const keyboardHeight = ref(0)
const isKeyboardOpen = ref(false)
const viewportHeight = ref(typeof window !== 'undefined' ? window.innerHeight : 0)
// Store the initial full height so we can compute keyboard offset
let fullHeight = typeof window !== 'undefined' ? window.innerHeight : 0
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function onViewportChangeRaw() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
const vv = window.visualViewport
if (!vv) return
const kbHeight = Math.max(0, fullHeight - vv.height)
keyboardHeight.value = kbHeight
isKeyboardOpen.value = kbHeight > 100
viewportHeight.value = vv.height
}, 50)
}
function onWindowResize() {
// Update full height when orientation changes or browser chrome resizes
const vv = window.visualViewport
if (vv && !isKeyboardOpen.value) {
fullHeight = vv.height
viewportHeight.value = vv.height
}
}
onMounted(() => {
const vv = window.visualViewport
if (vv) {
fullHeight = vv.height
viewportHeight.value = vv.height
vv.addEventListener('resize', onViewportChangeRaw)
vv.addEventListener('scroll', onViewportChangeRaw)
}
window.addEventListener('resize', onWindowResize)
})
onUnmounted(() => {
const vv = window.visualViewport
if (vv) {
vv.removeEventListener('resize', onViewportChangeRaw)
vv.removeEventListener('scroll', onViewportChangeRaw)
}
window.removeEventListener('resize', onWindowResize)
})
return { keyboardHeight, isKeyboardOpen, viewportHeight }
}
@@ -0,0 +1,41 @@
import { apiFetch } from '@/utils/api-fetch'
export interface WebSearchResult {
title: string
url: string
content?: string
imgSrc?: string
}
// Every other endpoint in this app is built from BASE_URL; this one was
// hardcoded to a leading slash, so embedded (BASE_URL `/aiui/`) it asked
// the HOST for `/api/web-search` instead of `/aiui/api/web-search`. Only
// the `/aiui/`-scoped location proxies SearXNG, so the request hit the
// node's own API gate and came back 403 — and the CSP, which allows the
// AIUI-scoped path, refused the connection on top. Web search could never
// have worked embedded, no matter what SearXNG was doing.
const BASE = import.meta.env.BASE_URL || '/'
export async function searchWeb(query: string): Promise<WebSearchResult[]> {
if (!query.trim()) return []
try {
const params = new URLSearchParams({ q: query.trim() })
const res = await apiFetch(`${BASE}api/web-search?${params}`, { signal: AbortSignal.timeout(10000) })
if (!res.ok) {
const body = await res.text().catch(() => '')
console.warn('[AIUI web-search]', res.status, body)
return []
}
const data = (await res.json()) as { results?: Array<WebSearchResult & { imgSrc?: string }>; error?: string }
if (data.error) {
console.warn('[AIUI web-search]', data.error)
return []
}
const results = data.results ?? []
console.log('[AIUI web-search]', query.slice(0, 50), '→', results.length, 'results')
return results
} catch (err) {
console.warn('[AIUI web-search] failed:', err)
return []
}
}
@@ -0,0 +1,48 @@
import { ref } from 'vue'
export function useWebShare() {
const isSupported = 'share' in navigator
const showFallback = ref(false)
const fallbackText = ref('')
const fallbackUrl = ref('')
async function share(data: { title?: string; text?: string; url?: string }) {
if (isSupported) {
try {
await navigator.share(data)
return true
} catch {
// User cancelled or not supported
}
}
// Fallback: show glass share sheet
fallbackText.value = data.text ?? data.title ?? ''
fallbackUrl.value = data.url ?? ''
showFallback.value = true
return false
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
return false
}
}
function closeFallback() {
showFallback.value = false
}
return {
isSupported,
showFallback,
fallbackText,
fallbackUrl,
share,
copyToClipboard,
closeFallback,
}
}