feat(13-11): wire requestArchyLibrary + fire content/library fetch from a live init-time event (GAP-FOUND)
AIUI asks for the library the same way it asks for content, and the
fetch now actually fires without anyone typing a magic phrase:
- useArchy.ts: requestArchyLibrary(scope) sibling of requestArchyContent
(13-06), same bridge call with kind: 'library', routed through the
existing setArchyContent so the songs bucket fills exactly the way
films already does.
- init() now calls both requestArchyContent('all','own') and
requestArchyLibrary('own') once, fire-and-forget, immediately after
archyBridge.init() — the GAP-FOUND fix. 13-06 built the whole
content:request/content:push machinery and unit-tested it end to end,
but nothing in the live UI ever called it (13-06-SUMMARY.md's Known
Limitations); the fetch is now triggered by a real init-time UI event,
not merely callable.
- useContentPanel.ts's setArchyContent now also opens the panel and
populates availableTabs/activeTab/panelTitle when Archy supplied
non-empty content — previously only the data refs were set while the
tab bar and panelOpen stayed whatever the last regex-driven chat turn
left them, so real content could sit fully populated and still never
render. An empty bundle never force-opens the panel.
Deviation (Rule 2, mirrors 13-06's own archyBridge.ts precedent): kind:
'library' genuinely needs a different node-side RPC (music.list-tracks,
real tag-extracted metadata) than content.* (ContentItem has no artist/
album/duration field at all) — contextBroker.ts's handleContentRequest
gained one branch (fetchLibraryContent) to route it, and
aiui-protocol.ts's AIUIContentRequest.kind union gained the 'library'
literal, and archyBridge.ts's requestArchyContent kind param widened to
match. No second channel, no new message type, no new listener — the
existing content:request/content:push channel and its kind discriminator
carry this exactly as 13-06 designed it to. Full detail in the SUMMARY.
neode-ui: 926/926 tests green, vue-tsc -b clean. aiui: 341/344 (3
pre-existing, documented failures unrelated to this plan — 13-06/13-10
already recorded them), vue-tsc --noEmit clean.
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -77,6 +77,40 @@ describe('useContentPanel', () => {
|
||||
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)
|
||||
// archyContentActive still flips true — an explicit empty library
|
||||
// is still a real answer from the node, distinct from "never asked".
|
||||
expect(panel.archyContentActive.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('openPlaceDetail and closePlaceDetail work', () => {
|
||||
const place = { id: 'p1', name: 'Test Place', address: '123 St', lat: 0, lng: 0 } as Place
|
||||
panel.openPlaceDetail(place)
|
||||
|
||||
@@ -126,6 +126,22 @@ export function useArchy() {
|
||||
|
||||
// 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 requestArchyContent('all', 'own')
|
||||
void requestArchyLibrary('own')
|
||||
}
|
||||
|
||||
/** Fetch context for all permitted categories */
|
||||
@@ -233,6 +249,38 @@ export function useArchy() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@@ -333,6 +381,7 @@ export function useArchy() {
|
||||
readFile,
|
||||
tailLogs,
|
||||
requestArchyContent,
|
||||
requestArchyLibrary,
|
||||
buildArchyContext,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,31 @@ export function useContentPanel() {
|
||||
panelSongs.value = bundle.songs ?? []
|
||||
panelPodcasts.value = bundle.podcasts ?? []
|
||||
archyContentActive.value = true
|
||||
|
||||
// 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.
|
||||
const archyTabs: ContentTab[] = []
|
||||
if (panelFilms.value.length > 0) archyTabs.push('film')
|
||||
if (panelSongs.value.length > 0) archyTabs.push('song')
|
||||
if (panelPodcasts.value.length > 0) archyTabs.push('podcast')
|
||||
if (archyTabs.length > 0) {
|
||||
availableTabs.value = [...archyTabs, 'prompt']
|
||||
if (!archyTabs.includes(activeTab.value)) activeTab.value = archyTabs[0]!
|
||||
if (panelFilms.value.length === 1) panelTitle.value = panelFilms.value[0]!.title
|
||||
else if (panelFilms.value.length > 1) panelTitle.value = `${panelFilms.value.length} Films`
|
||||
else if (panelSongs.value.length === 1) panelTitle.value = panelSongs.value[0]!.title
|
||||
else if (panelSongs.value.length > 1) panelTitle.value = `${panelSongs.value.length} Songs`
|
||||
else if (panelPodcasts.value.length === 1) panelTitle.value = panelPodcasts.value[0]!.title
|
||||
else if (panelPodcasts.value.length > 1) panelTitle.value = `${panelPodcasts.value.length} Podcasts`
|
||||
panelOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveTab(tab: ContentTab) {
|
||||
|
||||
@@ -299,9 +299,15 @@ export const archyBridge = {
|
||||
* false` and empty arrays if the media/files permission categories
|
||||
* aren't granted, rather than rejecting (mirrors `requestContext`'s
|
||||
* shape so `useArchy.ts` can check `.permitted` the same way).
|
||||
*
|
||||
* `'library'` (13-11) is `useArchy.ts`'s `requestArchyLibrary`'s kind —
|
||||
* resolves node-side to `music.list-tracks` instead of `content.*`
|
||||
* (`neode-ui`'s `aiui-protocol.ts`/`contextBroker.ts`), so the returned
|
||||
* `songs` carry real tag-extracted metadata rather than filename-derived
|
||||
* guesses.
|
||||
*/
|
||||
requestArchyContent(
|
||||
kind: 'films' | 'songs' | 'podcasts' | 'all',
|
||||
kind: 'films' | 'songs' | 'podcasts' | 'all' | 'library',
|
||||
scope?: 'own' | 'peers' | 'owned',
|
||||
): Promise<ContentResponse> {
|
||||
const id = generateId()
|
||||
|
||||
@@ -252,5 +252,64 @@ describe('ContextBroker', () => {
|
||||
)
|
||||
expect(freshCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
// 13-11: kind: 'library' is the one addition this wave makes to the
|
||||
// discriminator — it resolves to music.list-tracks, not content.*,
|
||||
// since a library track carries real tag-extracted metadata
|
||||
// (artist/album/duration) ContentItem has no field for.
|
||||
it("kind: 'library' calls music.list-tracks (not content.list-mine) and adapts the result into the songs bucket", async () => {
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.enableAll()
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
tracks: [
|
||||
{
|
||||
id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/Artist/Song.flac' },
|
||||
title: 'Song',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
album_artist: 'Artist',
|
||||
duration_secs: 200,
|
||||
has_tags: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await callContentRequest('req-lib', 'library', 'own')
|
||||
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'music.list-tracks' }))
|
||||
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'content.list-mine' }))
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'content:push',
|
||||
id: 'req-lib',
|
||||
kind: 'library',
|
||||
permitted: true,
|
||||
films: [],
|
||||
songs: expect.arrayContaining([expect.objectContaining({ title: 'Song', artist: 'Artist', album: 'Album' })]),
|
||||
}),
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it("kind: 'library' degrades to an empty songs bucket (not a thrown error) when music.list-tracks fails", async () => {
|
||||
const perms = useAIPermissionsStore()
|
||||
perms.enableAll()
|
||||
vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('no index yet'))
|
||||
|
||||
await callContentRequest('req-lib-err', 'library', 'own')
|
||||
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'content:push',
|
||||
id: 'req-lib-err',
|
||||
kind: 'library',
|
||||
permitted: true,
|
||||
films: [],
|
||||
songs: [],
|
||||
podcasts: [],
|
||||
}),
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,8 +15,10 @@ import { rpcClient } from '@/api/rpc-client'
|
||||
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||
import {
|
||||
adaptContentItems,
|
||||
adaptLibraryTracks,
|
||||
type ArchyContentBundle,
|
||||
type ArchyContentItem,
|
||||
type ArchyLibraryTrack,
|
||||
} from '@/composables/archyContentAdapter'
|
||||
|
||||
/** Wire shape of `content_owned::OwnedItem` (already-purchased peer
|
||||
@@ -366,7 +368,11 @@ export class ContextBroker {
|
||||
const requestedScope: 'own' | 'peers' | 'owned' =
|
||||
scope === 'peers' || scope === 'owned' ? scope : 'own'
|
||||
|
||||
const bundle = await this.fetchAdaptedContent(requestedScope)
|
||||
// 13-11: 'library' is the one kind value this wave adds — it resolves
|
||||
// to music.list-tracks (real tag-extracted metadata) instead of
|
||||
// content.* (see aiui-protocol.ts's AIUIContentRequest doc comment).
|
||||
const bundle =
|
||||
kind === 'library' ? await this.fetchLibraryContent() : await this.fetchAdaptedContent(requestedScope)
|
||||
|
||||
// Stale-response guard: a newer content:request has since started —
|
||||
// discard this result instead of flipping the grids back to older
|
||||
@@ -424,6 +430,34 @@ export class ContextBroker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 13-11: resolve a `content:request` whose `kind` is `'library'`. Calls
|
||||
* `music.list-tracks` (13-07) directly, not `content.*` — a library
|
||||
* track carries real tag-extracted metadata (artist/album/duration)
|
||||
* `ContentItem` has no field for at all, so `adaptContentItems`'s
|
||||
* generic mapping cannot produce it (`adaptToSong` always sets
|
||||
* `artist: ''`). `music.*` rides the same authenticated session as every
|
||||
* other RPC this broker calls; no separate permission check is added
|
||||
* here beyond `handleContentRequest`'s existing media/files gate, which
|
||||
* already ran before this is reached. A page cap of 500 matches
|
||||
* `music.list-tracks`'s own `MAX_TRACK_LIMIT` (T-13-41/T-13-73) — this
|
||||
* is the one-page-at-a-time truth the RPC itself enforces, not a second
|
||||
* cap invented here. Any failure (no index yet, RPC error) degrades to
|
||||
* an empty songs bucket rather than failing the whole request, matching
|
||||
* `fetchAdaptedContent`'s own error handling below.
|
||||
*/
|
||||
private async fetchLibraryContent(): Promise<ArchyContentBundle> {
|
||||
try {
|
||||
const res = await rpcClient.call<{ tracks: ArchyLibraryTrack[] }>({
|
||||
method: 'music.list-tracks',
|
||||
params: { limit: 500 },
|
||||
})
|
||||
return { films: [], songs: adaptLibraryTracks(res.tracks ?? []), podcasts: [] }
|
||||
} catch {
|
||||
return emptyBundle()
|
||||
}
|
||||
}
|
||||
|
||||
private async handleContextRequest(id: string, category: AIContextCategory, query?: string) {
|
||||
const perms = useAIPermissionsStore()
|
||||
|
||||
|
||||
@@ -65,11 +65,16 @@ export interface AIUIChatRequest {
|
||||
* RPC method or params (T-13-34); the broker decides the call. A single
|
||||
* generic channel (not one per content type) so 13-11's music-library wave
|
||||
* can extend `kind` without touching this file again.
|
||||
*
|
||||
* `'library'` (13-11) is the one addition this wave makes to the
|
||||
* discriminator: it routes to `music.list-tracks` (13-07) instead of
|
||||
* `content.*`, since a library track carries real tag-extracted metadata
|
||||
* (artist/album/duration) that `ContentItem` has no field for at all.
|
||||
*/
|
||||
export interface AIUIContentRequest {
|
||||
type: 'content:request'
|
||||
id: string
|
||||
kind: 'films' | 'songs' | 'podcasts' | 'all'
|
||||
kind: 'films' | 'songs' | 'podcasts' | 'all' | 'library'
|
||||
scope?: 'own' | 'peers' | 'owned'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user