diff --git a/aiui/packages/app/src/composables/useArchy.ts b/aiui/packages/app/src/composables/useArchy.ts index 32d73c13..045ad2a3 100644 --- a/aiui/packages/app/src/composables/useArchy.ts +++ b/aiui/packages/app/src/composables/useArchy.ts @@ -1,6 +1,8 @@ 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 } from '@aiui/core/types/content' import { mockArchyApps, mockArchySystem, mockArchyNetwork, mockArchyWallet, mockArchyBitcoin, mockArchyFiles, @@ -203,6 +205,34 @@ export function useArchy() { 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 + } + useContentPanel().setArchyContent({ + films: res.films as Film[], + songs: res.songs as Song[], + podcasts: res.podcasts as Podcast[], + }) + } catch (err) { + console.warn('[AIUI Archy] content 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) @@ -302,6 +332,7 @@ export function useArchy() { requestAction, readFile, tailLogs, + requestArchyContent, buildArchyContext, } } diff --git a/aiui/packages/app/src/composables/useContentPanel.ts b/aiui/packages/app/src/composables/useContentPanel.ts index 53f189fe..09c79d66 100644 --- a/aiui/packages/app/src/composables/useContentPanel.ts +++ b/aiui/packages/app/src/composables/useContentPanel.ts @@ -65,6 +65,14 @@ const selectedDesignSystemItem = ref(null) const longFormArticle = ref<{ content: string; title?: string } | null>(null) const pdfUrl = ref<{ url: string; title?: string } | null>(null) const mapPlaces = ref([]) +/** 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) export interface DesignSystemItem { id: string @@ -187,13 +195,18 @@ export function useContentPanel() { const visibleApps = showApps ? apps : [] const visibleCodeBlocks = showCode ? codeBlocks : [] - panelFilms.value = visibleFilms + // Archy-sourced films/songs/podcasts 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. + if (!archyContentActive.value) { + panelFilms.value = visibleFilms + panelSongs.value = visibleSongs + panelPodcasts.value = visiblePodcasts + } panelBooks.value = visibleBooks panelTVSeries.value = visibleTVSeries panelImages.value = visibleImages panelPlaces.value = visiblePlaces - panelSongs.value = visibleSongs - panelPodcasts.value = visiblePodcasts panelWebResults.value = visibleNews panelWebsites.value = visibleWebsites panelRecipes.value = visibleRecipes @@ -265,6 +278,27 @@ export function useContentPanel() { panelOpen.value = tabs.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). + */ + function setArchyContent(bundle: { films?: Film[]; songs?: Song[]; podcasts?: Podcast[] }) { + panelFilms.value = bundle.films ?? [] + panelSongs.value = bundle.songs ?? [] + panelPodcasts.value = bundle.podcasts ?? [] + archyContentActive.value = true + } + function setActiveTab(tab: ContentTab) { if (availableTabs.value.includes(tab)) activeTab.value = tab } @@ -461,6 +495,8 @@ export function useContentPanel() { panelPlaces, panelSongs, panelPodcasts, + archyContentActive, + setArchyContent, panelWebResults, panelWebsites, panelRecipes, diff --git a/aiui/packages/app/src/services/archyBridge.ts b/aiui/packages/app/src/services/archyBridge.ts index aea9c0ac..b4a16be7 100644 --- a/aiui/packages/app/src/services/archyBridge.ts +++ b/aiui/packages/app/src/services/archyBridge.ts @@ -12,6 +12,20 @@ interface ContextResponse { permitted: boolean } +/** `kind`/`scope` for a `content:request` — see neode-ui's + * `types/aiui-protocol.ts` (`AIUIContentRequest`/`ArchyContentPush`). Archy + * decides the RPC method from `scope`; this bundle carries only data, never + * a method name (T-13-34). Films/songs/podcasts are typed `unknown[]` here + * deliberately — this transport module has no reason to know AIUI's content + * shapes; `useArchy.ts`/`useContentPanel.ts` cast them to `Film[]`/`Song[]`/ + * `Podcast[]` at the one call site that does. */ +interface ContentResponse { + films: unknown[] + songs: unknown[] + podcasts: unknown[] + permitted: boolean +} + export interface ActionResponse { success: boolean error?: string @@ -87,6 +101,20 @@ function handleMessage(event: MessageEvent) { break } + case 'content:push': { + const pending = pendingRequests.get(msg.id) + if (pending) { + pendingRequests.delete(msg.id) + pending.resolve({ + films: Array.isArray(msg.films) ? msg.films : [], + songs: Array.isArray(msg.songs) ? msg.songs : [], + podcasts: Array.isArray(msg.podcasts) ? msg.podcasts : [], + permitted: msg.permitted !== false, + }) + } + break + } + case 'permissions:update': { currentPermissions = msg.categories || [] for (const cb of permissionsCallbacks) cb(currentPermissions) @@ -261,6 +289,39 @@ export const archyBridge = { }) }, + /** + * Request a batch of grid-ready content from Archy (D-12/D-14, AIUI-03). + * `kind` and `scope` are enums the broker interprets — this bridge never + * lets AIUI name an RPC method or params. Resolves with `permitted: + * 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). + */ + requestArchyContent( + kind: 'films' | 'songs' | 'podcasts' | 'all', + scope?: 'own' | 'peers' | 'owned', + ): Promise { + const id = generateId() + return new Promise((resolve, reject) => { + pendingRequests.set(id, { resolve: resolve as (v: unknown) => void, reject }) + postToParent({ + type: 'content:request', + id, + kind, + scope, + }) + + // Peer/owned scopes can fan out across multiple mesh/Tor round trips + // node-side — give this more room than the plain context:request's 10s. + setTimeout(() => { + if (pendingRequests.has(id)) { + pendingRequests.delete(id) + reject(new Error(`Content request timed out: ${kind}`)) + } + }, 30000) + }) + }, + /** Request Archy's theme info */ requestTheme() { postToParent({ type: 'theme:request' })