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:
co-authored by
Claude Opus 5
parent
c810b514ed
commit
b1c5d13850
@@ -671,7 +671,13 @@ export function useAI() {
|
|||||||
|
|
||||||
let systemPrompt = buildSystemPrompt(chatStore)
|
let systemPrompt = buildSystemPrompt(chatStore)
|
||||||
let clientSearchSucceeded = false
|
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)
|
const results = await searchWeb(userText)
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
systemPrompt += formatWebSearchContext(results)
|
systemPrompt += formatWebSearchContext(results)
|
||||||
@@ -794,7 +800,7 @@ export function useAI() {
|
|||||||
chatStore.isStreaming = true
|
chatStore.isStreaming = true
|
||||||
|
|
||||||
let systemPrompt = buildSystemPrompt(chatStore)
|
let systemPrompt = buildSystemPrompt(chatStore)
|
||||||
if (chatStore.webSearchEnabled && lastUserMsg.content.trim()) {
|
if (chatStore.webSearchEnabled && lastUserMsg.content.trim() && !archyBridge.isInArchy()) {
|
||||||
const results = await searchWeb(lastUserMsg.content)
|
const results = await searchWeb(lastUserMsg.content)
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
systemPrompt += formatWebSearchContext(results)
|
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)
|
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
|
// 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
|
availableTabs.value = tabsWithPrompt
|
||||||
activeTab.value = tabs[0] ?? 'prompt'
|
activeTab.value = orderedTabs[0] ?? 'prompt'
|
||||||
|
|
||||||
const showFilms = tabs.includes('film')
|
const showFilms = tabs.includes('film')
|
||||||
const showBooks = tabs.includes('book')
|
const showBooks = tabs.includes('book')
|
||||||
@@ -246,9 +272,26 @@ export function useContentPanel() {
|
|||||||
else if (visibleNews.length > 0) contentType.value = 'film'
|
else if (visibleNews.length > 0) contentType.value = 'film'
|
||||||
else contentType.value = 'film'
|
else contentType.value = 'film'
|
||||||
|
|
||||||
// Title follows the primary (first) tab
|
// Title follows the primary (first) tab — which, when the node supplied
|
||||||
const primary = tabs[0]
|
// content, is an Archy tab. Titling from `tabs[0]` (the regex path's
|
||||||
if (primary === 'place' && visiblePlaces.length > 0) {
|
// 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`
|
panelTitle.value = visiblePlaces.length === 1 ? visiblePlaces[0].name : `${visiblePlaces.length} Places`
|
||||||
} else if (primary === 'tvshow' && visibleTVSeries.length > 0) {
|
} else if (primary === 'tvshow' && visibleTVSeries.length > 0) {
|
||||||
panelTitle.value = visibleTVSeries.length === 1 ? visibleTVSeries[0].title : `${visibleTVSeries.length} TV Series`
|
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
|
// GAP-FOUND). Only touches these three refs when Archy actually
|
||||||
// supplied non-empty content — an empty/never-granted library must not
|
// supplied non-empty content — an empty/never-granted library must not
|
||||||
// force the panel open on every mount.
|
// force the panel open on every mount.
|
||||||
const archyTabs: ContentTab[] = []
|
// Largest bucket first — same reasoning as updatePanelFromText's copy:
|
||||||
if (panelFilms.value.length > 0) archyTabs.push('film')
|
// the tab that opens should be the one holding most of the answer.
|
||||||
if (panelSongs.value.length > 0) archyTabs.push('song')
|
const archyTabs: ContentTab[] = ([
|
||||||
if (panelPodcasts.value.length > 0) archyTabs.push('podcast')
|
['film', panelFilms.value.length],
|
||||||
if (panelImages.value.length > 0) archyTabs.push('image')
|
['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) {
|
if (archyTabs.length > 0) {
|
||||||
availableTabs.value = [...archyTabs, 'prompt']
|
availableTabs.value = [...archyTabs, 'prompt']
|
||||||
if (!archyTabs.includes(activeTab.value)) activeTab.value = archyTabs[0]!
|
if (!archyTabs.includes(activeTab.value)) activeTab.value = archyTabs[0]!
|
||||||
if (panelFilms.value.length === 1) panelTitle.value = panelFilms.value[0]!.title
|
const lead = archyTabs[0]!
|
||||||
else if (panelFilms.value.length > 1) panelTitle.value = `${panelFilms.value.length} Films`
|
if (lead === 'image') panelTitle.value = `${panelImages.value.length} Images`
|
||||||
else if (panelSongs.value.length === 1) panelTitle.value = panelSongs.value[0]!.title
|
else if (lead === 'film') {
|
||||||
else if (panelSongs.value.length > 1) panelTitle.value = `${panelSongs.value.length} Songs`
|
panelTitle.value = panelFilms.value.length === 1
|
||||||
else if (panelPodcasts.value.length === 1) panelTitle.value = panelPodcasts.value[0]!.title
|
? panelFilms.value[0]!.title : `${panelFilms.value.length} Films`
|
||||||
else if (panelPodcasts.value.length > 1) panelTitle.value = `${panelPodcasts.value.length} Podcasts`
|
} else if (lead === 'song') {
|
||||||
else if (panelImages.value.length > 0) panelTitle.value = `${panelImages.value.length} Images`
|
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
|
panelOpen.value = true
|
||||||
} else if (archyContentLoading.value) {
|
} else if (archyContentLoading.value) {
|
||||||
// The turn asked the node for content and got none back. Leaving the
|
// The turn asked the node for content and got none back. Leaving the
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ export interface WebSearchResult {
|
|||||||
imgSrc?: string
|
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[]> {
|
export async function searchWeb(query: string): Promise<WebSearchResult[]> {
|
||||||
if (!query.trim()) return []
|
if (!query.trim()) return []
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ q: query.trim() })
|
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) {
|
if (!res.ok) {
|
||||||
const body = await res.text().catch(() => '')
|
const body = await res.text().catch(() => '')
|
||||||
console.warn('[AIUI web-search]', res.status, body)
|
console.warn('[AIUI web-search]', res.status, body)
|
||||||
|
|||||||
Reference in New Issue
Block a user