From d25aea125f0ea6ba77c6e82eec74fabb494f710e Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 18:29:17 -0400 Subject: [PATCH] feat(13-11): wire requestArchyLibrary + fire content/library fetch from a live init-time event (GAP-FOUND) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../composables/__tests__/useArchy.test.ts | 73 +++++++++++++++++++ .../__tests__/useContentPanel.test.ts | 34 +++++++++ aiui/packages/app/src/composables/useArchy.ts | 49 +++++++++++++ .../app/src/composables/useContentPanel.ts | 25 +++++++ aiui/packages/app/src/services/archyBridge.ts | 8 +- .../services/__tests__/contextBroker.test.ts | 59 +++++++++++++++ neode-ui/src/services/contextBroker.ts | 36 ++++++++- neode-ui/src/types/aiui-protocol.ts | 7 +- 8 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 aiui/packages/app/src/composables/__tests__/useArchy.test.ts diff --git a/aiui/packages/app/src/composables/__tests__/useArchy.test.ts b/aiui/packages/app/src/composables/__tests__/useArchy.test.ts new file mode 100644 index 00000000..b5dd10f7 --- /dev/null +++ b/aiui/packages/app/src/composables/__tests__/useArchy.test.ts @@ -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).__AIUI_EMBEDDED__ = true + }) + + afterEach(() => { + Object.defineProperty(window, 'parent', { + value: originalParent, + writable: true, + configurable: true, + }) + delete (window as unknown as Record).__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 + 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') + }) +}) diff --git a/aiui/packages/app/src/composables/__tests__/useContentPanel.test.ts b/aiui/packages/app/src/composables/__tests__/useContentPanel.test.ts index 4aecafa8..a4efb610 100644 --- a/aiui/packages/app/src/composables/__tests__/useContentPanel.test.ts +++ b/aiui/packages/app/src/composables/__tests__/useContentPanel.test.ts @@ -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) diff --git a/aiui/packages/app/src/composables/useArchy.ts b/aiui/packages/app/src/composables/useArchy.ts index 045ad2a3..e7a0523a 100644 --- a/aiui/packages/app/src/composables/useArchy.ts +++ b/aiui/packages/app/src/composables/useArchy.ts @@ -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, } } diff --git a/aiui/packages/app/src/composables/useContentPanel.ts b/aiui/packages/app/src/composables/useContentPanel.ts index 09c79d66..824efe2d 100644 --- a/aiui/packages/app/src/composables/useContentPanel.ts +++ b/aiui/packages/app/src/composables/useContentPanel.ts @@ -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) { diff --git a/aiui/packages/app/src/services/archyBridge.ts b/aiui/packages/app/src/services/archyBridge.ts index cf3c71e2..0aa51a91 100644 --- a/aiui/packages/app/src/services/archyBridge.ts +++ b/aiui/packages/app/src/services/archyBridge.ts @@ -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 { const id = generateId() diff --git a/neode-ui/src/services/__tests__/contextBroker.test.ts b/neode-ui/src/services/__tests__/contextBroker.test.ts index 192c6185..47bfec62 100644 --- a/neode-ui/src/services/__tests__/contextBroker.test.ts +++ b/neode-ui/src/services/__tests__/contextBroker.test.ts @@ -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), + ) + }) }) }) diff --git a/neode-ui/src/services/contextBroker.ts b/neode-ui/src/services/contextBroker.ts index 275fa522..1735963f 100644 --- a/neode-ui/src/services/contextBroker.ts +++ b/neode-ui/src/services/contextBroker.ts @@ -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 { + 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() diff --git a/neode-ui/src/types/aiui-protocol.ts b/neode-ui/src/types/aiui-protocol.ts index 11748275..577663f6 100644 --- a/neode-ui/src/types/aiui-protocol.ts +++ b/neode-ui/src/types/aiui-protocol.ts @@ -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' }