diff --git a/aiui/packages/app/src/composables/useAI.ts b/aiui/packages/app/src/composables/useAI.ts index 99021375..8fa0d852 100644 --- a/aiui/packages/app/src/composables/useAI.ts +++ b/aiui/packages/app/src/composables/useAI.ts @@ -671,7 +671,13 @@ export function useAI() { let systemPrompt = buildSystemPrompt(chatStore) let clientSearchSucceeded = false - if (chatStore.webSearchEnabled && userText.trim()) { + // 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) @@ -794,7 +800,7 @@ export function useAI() { chatStore.isStreaming = true let systemPrompt = buildSystemPrompt(chatStore) - if (chatStore.webSearchEnabled && lastUserMsg.content.trim()) { + if (chatStore.webSearchEnabled && lastUserMsg.content.trim() && !archyBridge.isInArchy()) { const results = await searchWeb(lastUserMsg.content) if (results.length > 0) { systemPrompt += formatWebSearchContext(results) diff --git a/aiui/packages/app/src/composables/useContentPanel.ts b/aiui/packages/app/src/composables/useContentPanel.ts index 26198410..255bae1f 100644 --- a/aiui/packages/app/src/composables/useContentPanel.ts +++ b/aiui/packages/app/src/composables/useContentPanel.ts @@ -168,10 +168,36 @@ export function useContentPanel() { } 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 = tabs.length > 0 ? [...tabs, 'prompt' as const] : ['prompt' as const] + const tabsWithPrompt = orderedTabs.length > 0 ? [...orderedTabs, 'prompt' as const] : ['prompt' as const] availableTabs.value = tabsWithPrompt - activeTab.value = tabs[0] ?? 'prompt' + activeTab.value = orderedTabs[0] ?? 'prompt' const showFilms = tabs.includes('film') const showBooks = tabs.includes('book') @@ -246,9 +272,26 @@ export function useContentPanel() { else if (visibleNews.length > 0) contentType.value = 'film' else contentType.value = 'film' - // Title follows the primary (first) tab - const primary = tabs[0] - if (primary === 'place' && visiblePlaces.length > 0) { + // 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` @@ -329,21 +372,32 @@ export function useContentPanel() { // 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 (panelImages.value.length > 0) archyTabs.push('image') + // 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]! - 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` - else if (panelImages.value.length > 0) panelTitle.value = `${panelImages.value.length} Images` + 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 diff --git a/aiui/packages/app/src/composables/useWebSearch.ts b/aiui/packages/app/src/composables/useWebSearch.ts index 4e36dd9b..45a11b3f 100644 --- a/aiui/packages/app/src/composables/useWebSearch.ts +++ b/aiui/packages/app/src/composables/useWebSearch.ts @@ -7,11 +7,20 @@ export interface WebSearchResult { 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 { if (!query.trim()) return [] try { const params = new URLSearchParams({ q: query.trim() }) - const res = await apiFetch(`/api/web-search?${params}`, { signal: AbortSignal.timeout(10000) }) + 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)