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:
co-authored by
Claude Opus 5
parent
f7c541e867
commit
9abc162394
@@ -85,6 +85,36 @@
|
||||
@dismiss="dismissToolConfirm"
|
||||
/>
|
||||
|
||||
<!-- A tool the operator asked for was blocked by an ungranted
|
||||
category. Trusted chrome, and Teleported to body for the same
|
||||
reason ToolConfirmModal is: a transformed ancestor would trap
|
||||
position:fixed. This only OFFERS the settings screen — it never
|
||||
changes a grant itself, so nothing the iframe or the model says
|
||||
can widen permissions. -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="permissionNeeded.length" class="chat-permission-offer" role="status">
|
||||
<p class="text-sm text-white/85">
|
||||
{{ t('chat.permissionNeeded', { categories: permissionNeededLabels }) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button class="chat-permission-btn" @click="openAISettings">
|
||||
{{ t('chat.openAISettings') }}
|
||||
</button>
|
||||
<button
|
||||
class="chat-permission-dismiss"
|
||||
:aria-label="t('common.dismiss')"
|
||||
@click="permissionNeeded = []"
|
||||
>
|
||||
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -94,6 +124,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ContextBroker } from '@/services/contextBroker'
|
||||
import ToolConfirmModal from '@/components/ToolConfirmModal.vue'
|
||||
import { AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -227,6 +258,32 @@ function onToolConfirmExpired(e: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// A tool the operator's question needed was refused because its category
|
||||
// is off. The node reports WHICH categories; we name them and offer the
|
||||
// screen that owns the toggles. Never flips a toggle here — the operator
|
||||
// decides, on the settings screen, in the trusted chrome.
|
||||
const permissionNeeded = ref<string[]>([])
|
||||
|
||||
const permissionNeededLabels = computed(() =>
|
||||
permissionNeeded.value
|
||||
.map((id) => AI_PERMISSION_CATEGORIES.find((c) => c.id === id)?.label ?? id)
|
||||
.join(', '),
|
||||
)
|
||||
|
||||
function onPermissionNeeded(e: Event) {
|
||||
const detail = (e as CustomEvent).detail as { categories?: unknown }
|
||||
const categories = Array.isArray(detail?.categories) ? detail.categories : []
|
||||
const known = categories.filter(
|
||||
(c): c is string => typeof c === 'string' && AI_PERMISSION_CATEGORIES.some((k) => k.id === c),
|
||||
)
|
||||
if (known.length) permissionNeeded.value = known
|
||||
}
|
||||
|
||||
function openAISettings() {
|
||||
permissionNeeded.value = []
|
||||
router.push({ path: '/dashboard/settings', hash: '#ai-data-access' })
|
||||
}
|
||||
|
||||
function onAiuiMessage(event: MessageEvent) {
|
||||
if (!aiuiUrl.value) return
|
||||
// Validate origin — only accept messages from AIUI
|
||||
@@ -258,6 +315,8 @@ function armChatLive() {
|
||||
window.addEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
|
||||
window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
|
||||
window.addEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
|
||||
window.removeEventListener('aiui:permission-needed', onPermissionNeeded)
|
||||
window.addEventListener('aiui:permission-needed', onPermissionNeeded)
|
||||
broker?.stop()
|
||||
broker = null
|
||||
if (aiuiUrl.value) {
|
||||
@@ -279,6 +338,7 @@ onDeactivated(() => {
|
||||
window.removeEventListener('message', onAiuiMessage)
|
||||
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
|
||||
window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
|
||||
window.removeEventListener('aiui:permission-needed', onPermissionNeeded)
|
||||
broker?.stop()
|
||||
broker = null
|
||||
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
|
||||
@@ -294,6 +354,7 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onAiuiMessage)
|
||||
window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest)
|
||||
window.removeEventListener('aiui:tool-confirm-expired', onToolConfirmExpired)
|
||||
window.removeEventListener('aiui:permission-needed', onPermissionNeeded)
|
||||
broker?.stop()
|
||||
broker = null
|
||||
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
|
||||
@@ -301,6 +362,55 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Teleported to body, so this is positioned against the viewport, not the
|
||||
chat panel. Sits above the iframe but below the confirm modal — a
|
||||
blocking decision must always win over a passive offer. */
|
||||
.chat-permission-offer {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(1.25rem + var(--safe-bottom, 0px));
|
||||
z-index: 60;
|
||||
max-width: min(40rem, calc(100vw - 2rem));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border-radius: 0.875rem;
|
||||
background: rgba(24, 24, 27, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.chat-permission-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: #fdba74;
|
||||
background: rgba(251, 146, 60, 0.14);
|
||||
border: 1px solid rgba(251, 146, 60, 0.3);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-permission-btn:hover {
|
||||
background: rgba(251, 146, 60, 0.24);
|
||||
}
|
||||
|
||||
.chat-permission-dismiss {
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-permission-dismiss:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.chat-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
Reference in New Issue
Block a user