fix(aiui): the content surface renders what the assistant found

Four defects, one visible symptom: a correct prose answer beside an
empty grid.

1. The assistant's curated RPC bridge had an arm only for
   `content.list-mine`. `tools.rs` mapped the `peers`, `purchased` and
   `films` scopes onto three real, dispatcher-registered handlers that
   `assistant_dispatch_tool` had never heard of, so every non-"own"
   scope died on its catch-all. Downstream that read as "the peers have
   no content" — it was a missing match arm, and the tool never ran.
   Regression test added: every scope the schema advertises must reach a
   real handler.

2. `content.browse-all-peers` wrapped its whole fan-out in one
   `timeout(..).unwrap_or_default()`, which DISCARDED every completed
   batch the moment the budget expired. One slow peer turned a
   partly-successful browse into "0 reached, 16 unreachable". Observed
   live on archi-dev-box: back-to-back calls returned real peer items,
   then nothing. Now accumulates per batch and checks a deadline between
   them, so partial results always survive. Budget 20s -> 45s: two
   batches of eight at a 10s per-peer timeout had no headroom at all.

3. `assistant.chat` returned only `{ text }`. The structured results of
   any content tool the turn ran were dropped inside the loop, so the
   surface had nothing to render. The turn now carries them through
   (captured raw, before the untrusted wrap, since they go to a renderer
   that treats every field as inert data, never back into the prompt).

4. The adapter classified images as 'excluded' and dropped them. A node
   sharing mostly photos rendered as an empty grid while AIUI's image
   grid sat unused. Images now have a bucket, with the paid-lock and
   extension-fallback handling audio and video already had.

Also: the panel says "Loading…" while a turn is in flight and "Nothing
found" when it comes back empty, instead of leaving the previous
query's heading standing as though it answered this one; the system
prompt tells the model to call the content tool and summarise rather
than re-list what the cards already show; and a refused tool now names
its permission category so the trusted chrome can offer the settings
screen instead of leaving "I don't have a tool for that" as the only
clue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-07 05:00:54 -04:00
co-authored by Claude Opus 5
parent f7c541e867
commit 9abc162394
18 changed files with 650 additions and 56 deletions
+28 -1
View File
@@ -6,7 +6,9 @@ import type { ImageAttachment } from '@aiui/core/types/message'
import { usePersonaStore } from '@/stores/personas'
import { useMemoryStore } from '@/stores/memory'
import { useArchy } from '@/composables/useArchy'
import { archyBridge } from '@/services/archyBridge'
import { archyBridge, type ChatSurface } from '@/services/archyBridge'
import { useContentPanel } from '@/composables/useContentPanel'
import type { Film, Song, Podcast, ImageItem } from '@aiui/core/types/content'
import { useCodeContext } from '@/composables/useCodeContext'
import { apiFetch } from '@/utils/api-fetch'
import { useSettingsStore } from '@/stores/settings'
@@ -392,16 +394,41 @@ async function streamViaArchy(
): Promise<void> {
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
const text = lastUser?.content ?? ''
const { beginArchyContentLoad, setArchyContent } = useContentPanel()
beginArchyContentLoad()
try {
const result = await archyBridge.sendChat(text)
if (signal?.aborted) return
// The turn's tool results, rendered — not just described. A node that
// listed twelve shared files used to produce a correct paragraph next
// to an empty grid, because this was the point the structured results
// were dropped.
setArchyContent(mergeChatSurfaces(result.surfaces))
onToken(result.text)
} catch (err) {
if (signal?.aborted) return
// Clear the 'Loading…' heading — an errored turn must not leave the
// panel claiming it is still working.
setArchyContent({})
onError(err instanceof Error ? err.message : 'Archy chat request failed')
}
}
/**
* Flatten a turn's surfaces into the one bundle the panel renders. A
* single turn can legitimately run `content_list` more than once (own
* files AND peer films, say); concatenating rather than letting the last
* call win is what keeps both visible.
*/
function mergeChatSurfaces(surfaces: ChatSurface[] = []) {
return {
films: surfaces.flatMap((s) => (s.bundle?.films ?? []) as Film[]),
songs: surfaces.flatMap((s) => (s.bundle?.songs ?? []) as Song[]),
podcasts: surfaces.flatMap((s) => (s.bundle?.podcasts ?? []) as Podcast[]),
images: surfaces.flatMap((s) => (s.bundle?.images ?? []) as ImageItem[]),
}
}
async function readSSE(
res: Response,
onData: (data: string) => void,
+15 -3
View File
@@ -2,7 +2,7 @@ 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 type { Film, Song, Podcast, ImageItem } from '@aiui/core/types/content'
import {
mockArchyApps, mockArchySystem, mockArchyNetwork,
mockArchyWallet, mockArchyBitcoin, mockArchyFiles,
@@ -244,6 +244,7 @@ export function useArchy() {
films: res.films as Film[],
songs: panel.panelSongs.value,
podcasts: res.podcasts as Podcast[],
images: res.images as ImageItem[],
})
} catch (err) {
console.warn('[AIUI Archy] content fetch failed:', (err as Error)?.message ?? err)
@@ -283,9 +284,10 @@ export function useArchy() {
// they arrive. A scope that times out costs only its own results.
const films: Film[] = []
const podcasts: Podcast[] = []
const images: ImageItem[] = []
const seen = new Set<string>()
const merge = (res: { films?: unknown; podcasts?: unknown } | null) => {
const merge = (res: { films?: unknown; podcasts?: unknown; images?: unknown } | null) => {
if (!res) return false
let added = false
for (const f of (res.films ?? []) as Film[]) {
@@ -298,12 +300,22 @@ export function useArchy() {
if (seen.has(k)) continue
seen.add(k); podcasts.push(p); added = true
}
for (const im of (res.images ?? []) as ImageItem[]) {
const k = `img:${im.id}`
if (seen.has(k)) continue
seen.add(k); images.push(im); added = true
}
return added
}
const paint = () => {
const panel = useContentPanel()
panel.setArchyContent({ films: [...films], songs: panel.panelSongs.value, podcasts: [...podcasts] })
panel.setArchyContent({
films: [...films],
songs: panel.panelSongs.value,
podcasts: [...podcasts],
images: [...images],
})
}
const fetchScope = (scope: 'own' | 'owned' | 'peers') =>
@@ -73,6 +73,12 @@ const mapPlaces = ref<Place[]>([])
* 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)
/** A chat turn that may produce content is in flight. Until it resolves,
* the panel heading must NOT keep advertising the previous query's
* results the operator reads that as the answer to what they just
* asked. `beginArchyContentLoad` raises this and `setArchyContent`
* lowers it. */
const archyContentLoading = ref(false)
export interface DesignSystemItem {
id: string
@@ -195,17 +201,20 @@ export function useContentPanel() {
const visibleApps = showApps ? apps : []
const visibleCodeBlocks = showCode ? codeBlocks : []
// 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.
// Archy-sourced films/songs/podcasts/images 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. Images
// joined this set when the adapter started carrying shared photos;
// leaving them out here would have let the regex path immediately
// wipe the grid the node had just filled.
if (!archyContentActive.value) {
panelFilms.value = visibleFilms
panelSongs.value = visibleSongs
panelPodcasts.value = visiblePodcasts
panelImages.value = visibleImages
}
panelBooks.value = visibleBooks
panelTVSeries.value = visibleTVSeries
panelImages.value = visibleImages
panelPlaces.value = visiblePlaces
panelWebResults.value = visibleNews
panelWebsites.value = visibleWebsites
@@ -292,10 +301,23 @@ export function useContentPanel() {
* `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[] }) {
/** A content-capable chat turn just started. Clears the stale heading
* so the panel says what it is doing rather than what it last found. */
function beginArchyContentLoad() {
archyContentLoading.value = true
panelTitle.value = 'Loading…'
}
function setArchyContent(bundle: {
films?: Film[]
songs?: Song[]
podcasts?: Podcast[]
images?: ImageItem[]
}) {
panelFilms.value = bundle.films ?? []
panelSongs.value = bundle.songs ?? []
panelPodcasts.value = bundle.podcasts ?? []
panelImages.value = bundle.images ?? []
archyContentActive.value = true
// 13-11 (GAP-FOUND 2026-08-03): `availableTabs`/`activeTab`/`panelOpen`
@@ -311,6 +333,7 @@ export function useContentPanel() {
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')
if (archyTabs.length > 0) {
availableTabs.value = [...archyTabs, 'prompt']
if (!archyTabs.includes(activeTab.value)) activeTab.value = archyTabs[0]!
@@ -320,8 +343,15 @@ export function useContentPanel() {
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`
panelOpen.value = true
} else if (archyContentLoading.value) {
// The turn asked the node for content and got none back. Leaving the
// previous query's heading up would claim those results answer THIS
// question; say plainly that there was nothing.
panelTitle.value = 'Nothing found'
}
archyContentLoading.value = false
}
function setActiveTab(tab: ContentTab) {
@@ -521,6 +551,8 @@ export function useContentPanel() {
panelSongs,
panelPodcasts,
archyContentActive,
archyContentLoading,
beginArchyContentLoad,
setArchyContent,
panelWebResults,
panelWebsites,
+27 -3
View File
@@ -23,6 +23,7 @@ interface ContentResponse {
films: unknown[]
songs: unknown[]
podcasts: unknown[]
images: unknown[]
permitted: boolean
}
@@ -31,6 +32,21 @@ export interface ActionResponse {
error?: string
}
/** One content-tool result from a chat turn, carrying the same
* `films`/`songs`/`podcasts` buckets `content:push` delivers see
* neode-ui's `ArchyChatSurface`. `scope` names what was asked for
* (`own`/`peers`/`purchased`/`films`) so the surface can title itself. */
export interface ChatSurface {
tool: string
scope?: string
bundle: {
films: unknown[]
songs: unknown[]
podcasts: unknown[]
images: unknown[]
}
}
interface ThemeInfo {
accent: string
mode: 'dark'
@@ -93,7 +109,10 @@ function handleMessage(event: MessageEvent) {
if (pending) {
pendingRequests.delete(msg.id)
if (msg.success) {
pending.resolve({ text: msg.text ?? '' })
pending.resolve({
text: msg.text ?? '',
surfaces: Array.isArray(msg.surfaces) ? msg.surfaces : [],
})
} else {
pending.reject(new Error(msg.error || 'Chat request failed'))
}
@@ -109,6 +128,7 @@ function handleMessage(event: MessageEvent) {
films: Array.isArray(msg.films) ? msg.films : [],
songs: Array.isArray(msg.songs) ? msg.songs : [],
podcasts: Array.isArray(msg.podcasts) ? msg.podcasts : [],
images: Array.isArray(msg.images) ? msg.images : [],
permitted: msg.permitted !== false,
})
}
@@ -266,9 +286,13 @@ export const archyBridge = {
/**
* Send a chat turn to Archy's node-side assistant loop (D-01: the model
* key and the tool-calling loop live on the node, never in this bundle).
* Returns the assistant's final text answer for this turn.
* Resolves with the turn's final text AND any content the tools it ran
* produced, already adapted to grid records by the host broker this
* is what lets an answer be rendered as a surface instead of only
* described in prose. `surfaces` is `[]` for a turn that ran no content
* tool, so callers never branch on undefined.
*/
sendChat(text: string): Promise<{ text: string }> {
sendChat(text: string): Promise<{ text: string; surfaces: ChatSurface[] }> {
const id = generateId()
return new Promise((resolve, reject) => {
pendingRequests.set(id, { resolve: resolve as (v: unknown) => void, reject })