Archipelago — open-source initial import
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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.0–friendly 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: . 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,
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user