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()
|
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', () => {
|
it('openPlaceDetail and closePlaceDetail work', () => {
|
||||||
const place = { id: 'p1', name: 'Test Place', address: '123 St', lat: 0, lng: 0 } as Place
|
const place = { id: 'p1', name: 'Test Place', address: '123 St', lat: 0, lng: 0 } as Place
|
||||||
panel.openPlaceDetail(place)
|
panel.openPlaceDetail(place)
|
||||||
|
|||||||
@@ -126,6 +126,22 @@ export function useArchy() {
|
|||||||
|
|
||||||
// Request theme on init
|
// Request theme on init
|
||||||
archyBridge.requestTheme()
|
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 */
|
/** 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 */
|
/** Apply accent color as CSS custom property */
|
||||||
function applyAccentColor(color: string) {
|
function applyAccentColor(color: string) {
|
||||||
document.documentElement.style.setProperty('--color-accent', color)
|
document.documentElement.style.setProperty('--color-accent', color)
|
||||||
@@ -333,6 +381,7 @@ export function useArchy() {
|
|||||||
readFile,
|
readFile,
|
||||||
tailLogs,
|
tailLogs,
|
||||||
requestArchyContent,
|
requestArchyContent,
|
||||||
|
requestArchyLibrary,
|
||||||
buildArchyContext,
|
buildArchyContext,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,6 +297,31 @@ export function useContentPanel() {
|
|||||||
panelSongs.value = bundle.songs ?? []
|
panelSongs.value = bundle.songs ?? []
|
||||||
panelPodcasts.value = bundle.podcasts ?? []
|
panelPodcasts.value = bundle.podcasts ?? []
|
||||||
archyContentActive.value = true
|
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) {
|
function setActiveTab(tab: ContentTab) {
|
||||||
|
|||||||
@@ -299,9 +299,15 @@ export const archyBridge = {
|
|||||||
* false` and empty arrays if the media/files permission categories
|
* false` and empty arrays if the media/files permission categories
|
||||||
* aren't granted, rather than rejecting (mirrors `requestContext`'s
|
* aren't granted, rather than rejecting (mirrors `requestContext`'s
|
||||||
* shape so `useArchy.ts` can check `.permitted` the same way).
|
* 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(
|
requestArchyContent(
|
||||||
kind: 'films' | 'songs' | 'podcasts' | 'all',
|
kind: 'films' | 'songs' | 'podcasts' | 'all' | 'library',
|
||||||
scope?: 'own' | 'peers' | 'owned',
|
scope?: 'own' | 'peers' | 'owned',
|
||||||
): Promise<ContentResponse> {
|
): Promise<ContentResponse> {
|
||||||
const id = generateId()
|
const id = generateId()
|
||||||
|
|||||||
@@ -252,5 +252,64 @@ describe('ContextBroker', () => {
|
|||||||
)
|
)
|
||||||
expect(freshCalls).toHaveLength(1)
|
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 { fileBrowserClient } from '@/api/filebrowser-client'
|
||||||
import {
|
import {
|
||||||
adaptContentItems,
|
adaptContentItems,
|
||||||
|
adaptLibraryTracks,
|
||||||
type ArchyContentBundle,
|
type ArchyContentBundle,
|
||||||
type ArchyContentItem,
|
type ArchyContentItem,
|
||||||
|
type ArchyLibraryTrack,
|
||||||
} from '@/composables/archyContentAdapter'
|
} from '@/composables/archyContentAdapter'
|
||||||
|
|
||||||
/** Wire shape of `content_owned::OwnedItem` (already-purchased peer
|
/** Wire shape of `content_owned::OwnedItem` (already-purchased peer
|
||||||
@@ -366,7 +368,11 @@ export class ContextBroker {
|
|||||||
const requestedScope: 'own' | 'peers' | 'owned' =
|
const requestedScope: 'own' | 'peers' | 'owned' =
|
||||||
scope === 'peers' || scope === 'owned' ? scope : 'own'
|
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 —
|
// Stale-response guard: a newer content:request has since started —
|
||||||
// discard this result instead of flipping the grids back to older
|
// 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) {
|
private async handleContextRequest(id: string, category: AIContextCategory, query?: string) {
|
||||||
const perms = useAIPermissionsStore()
|
const perms = useAIPermissionsStore()
|
||||||
|
|
||||||
|
|||||||
@@ -65,11 +65,16 @@ export interface AIUIChatRequest {
|
|||||||
* RPC method or params (T-13-34); the broker decides the call. A single
|
* 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
|
* generic channel (not one per content type) so 13-11's music-library wave
|
||||||
* can extend `kind` without touching this file again.
|
* 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 {
|
export interface AIUIContentRequest {
|
||||||
type: 'content:request'
|
type: 'content:request'
|
||||||
id: string
|
id: string
|
||||||
kind: 'films' | 'songs' | 'podcasts' | 'all'
|
kind: 'films' | 'songs' | 'podcasts' | 'all' | 'library'
|
||||||
scope?: 'own' | 'peers' | 'owned'
|
scope?: 'own' | 'peers' | 'owned'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user