fix(aiui): node content outranks the prose surface, and web search resolves

Browser-verified on archi-dev-box; all three were only visible by
driving the real UI.

The tab bar. `setArchyContent` put the node's grids up, then
`updatePanelFromText` replaced the bar with tabs inferred from the reply
text. "show me my own shared content" therefore landed on an "AI Brief"
— a prose restatement of the answer already on the left — with the
populated image grid no longer reachable. Guarding the panel arrays was
not enough: they held the right data while the tab bar had discarded the
way to see it. Archy tabs now lead, and the title follows the leading
tab. The prose stays; it just is not the only thing shown.

Tab order follows bucket size. A node with 13 photos and 2 tracks opened
on Songs and titled itself "2 Songs" for a 15-item answer.

Web search never worked embedded. `searchWeb` hardcoded
`/api/web-search` while every other call is built from BASE_URL. Under
`/aiui/` that asked the HOST for a path only the AIUI-scoped nginx
location serves, so it hit the node's API gate for a 403 and the CSP
refused the connection on top. Now BASE-relative. Additionally, the
embedded path skips the client-side search entirely: `streamViaArchy`
sends only the user's text, so the system prompt those results were
folded into is never transmitted — it was a round trip and a console
error per turn whose output provably reached no model. Web search for
the embedded path belongs node-side, with the other tools.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-07 05:48:39 -04:00
co-authored by Claude Opus 5
parent c810b514ed
commit b1c5d13850
3 changed files with 89 additions and 20 deletions
+8 -2
View File
@@ -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)
@@ -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
@@ -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<WebSearchResult[]> {
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)