feat: complete AIUI integration — all 31 overnight tasks

- Protocol: 10 context categories (apps, system, network, bitcoin, media, files, notes, search, ai-local, wallet)
- ContextBroker: real data wiring for all categories with sanitization
- Permissions: user toggles for all categories in Settings
- Nginx: Claude API, OpenRouter, SearXNG proxy pass-through
- Actions: launch-app, search-web, install-app handlers
- Chat.vue: loading state + connection indicator
- Integration test page: test-aiui.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 14:34:02 +00:00
co-authored by Claude Opus 4.6
parent 81473db38b
commit 7b56927c3c
26 changed files with 1253 additions and 142 deletions
+61 -4
View File
@@ -1,6 +1,6 @@
<template>
<div class="chat-fullscreen">
<!-- Close button: top-left, glass pill, returns to previous view -->
<!-- Close button + connection indicator -->
<div class="chat-mode-pill">
<button class="chat-close-btn" @click="closeChat">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -8,8 +8,23 @@
</svg>
<span class="text-xs font-medium">Close</span>
</button>
<div
v-if="aiuiConnected"
class="w-2 h-2 rounded-full bg-green-400 ml-2 shadow-[0_0_6px_rgba(74,222,128,0.5)]"
title="AIUI connected"
/>
</div>
<!-- Loading indicator while iframe loads -->
<Transition name="fade">
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading">
<div class="glass-card p-8 flex flex-col items-center gap-4">
<div class="chat-loading-spinner" />
<p class="text-sm text-white/60">Loading AI assistant...</p>
</div>
</div>
</Transition>
<!-- AIUI iframe -->
<iframe
v-if="aiuiUrl"
@@ -48,18 +63,17 @@ import { ContextBroker } from '@/services/contextBroker'
const router = useRouter()
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
const aiuiConnected = ref(false)
let broker: ContextBroker | null = null
const aiuiUrl = computed(() => {
const envUrl = import.meta.env.VITE_AIUI_URL
if (envUrl) return `${envUrl}?embedded=true`
// Production: served from /aiui/ via nginx proxy
if (import.meta.env.PROD) return '/aiui/?embedded=true'
return ''
})
function closeChat() {
// Go back if there's history, otherwise go to dashboard
if (window.history.length > 1) {
router.back()
} else {
@@ -67,8 +81,16 @@ function closeChat() {
}
}
function onAiuiMessage(event: MessageEvent) {
if (!aiuiUrl.value) return
const msg = event.data
if (msg && msg.type === 'ready') {
aiuiConnected.value = true
}
}
onMounted(() => {
// Start context broker if AIUI URL is available
window.addEventListener('message', onAiuiMessage)
if (aiuiUrl.value) {
broker = new ContextBroker(aiuiFrame, aiuiUrl.value)
broker.start()
@@ -76,7 +98,42 @@ onMounted(() => {
})
onBeforeUnmount(() => {
window.removeEventListener('message', onAiuiMessage)
broker?.stop()
broker = null
})
</script>
<style scoped>
.chat-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.chat-loading-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #fb923c;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+80 -14
View File
@@ -228,18 +228,40 @@
</div>
<!-- Quick Start Goals - shown in Pro mode below the overview cards -->
<div v-if="uiMode.isGamer" class="path-option-card cursor-default px-6 py-6">
<h2 class="text-xl font-semibold text-white/96 mb-2">Quick Start Goals</h2>
<p class="text-sm text-white/60 mb-4">Not sure where to start? Try a guided setup.</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<RouterLink
v-for="goal in topGoals"
:key="goal.id"
:to="`/dashboard/goals/${goal.id}`"
class="path-action-button path-action-button--continue flex items-center justify-center gap-3"
>
<span>{{ goal.title }}</span>
</RouterLink>
<div
v-if="uiMode.isGamer && showQuickStart"
class="home-card"
:class="{ 'home-card-animate': animateCards }"
style="--card-stagger: 4"
>
<div class="home-card-shell">
<div class="home-card-inner px-6 py-6">
<div class="flex items-start justify-between mb-2">
<div>
<h2 class="text-xl font-semibold text-white/96 mb-1">Quick Start Goals</h2>
<p class="text-sm text-white/60 mb-4">Not sure where to start? Try a guided setup.</p>
</div>
<button
@click="dismissQuickStart"
class="text-white/40 hover:text-white/80 transition-colors p-1 -mt-1 -mr-1"
title="Dismiss"
>
<svg class="w-5 h-5" 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 class="grid grid-cols-1 md:grid-cols-3 gap-3">
<RouterLink
v-for="goal in topGoals"
:key="goal.id"
:to="`/dashboard/goals/${goal.id}`"
class="home-card-btn path-action-button path-action-button--continue flex items-center justify-center gap-3"
>
<span>{{ goal.title }}</span>
</RouterLink>
</div>
</div>
</div>
</div>
@@ -266,6 +288,11 @@ import EasyHome from '@/components/EasyHome.vue'
const uiMode = useUIModeStore()
const topGoals = GOALS.slice(0, 3)
// Apps required by the top 3 goals — if all installed, no need to show Quick Start
const QUICK_START_APPS = [...new Set(topGoals.flatMap((g) => g.requiredApps))]
const QUICK_START_KEY = 'archipelago-quick-start-dismissed'
const QUICK_START_RESHOW_LOGINS = 5
const store = useAppStore()
const loginTransition = useLoginTransitionStore()
@@ -333,9 +360,45 @@ watch(() => loginTransition.startWelcomeTyping, (shouldStart) => {
const version = computed(() => store.serverInfo?.version || '0.0.0')
const packages = computed(() => store.packages)
const appCount = computed(() => Object.keys(packages.value).length)
const runningCount = computed(() =>
const runningCount = computed(() =>
Object.values(packages.value).filter(pkg => pkg.state === PackageState.Running).length
)
// Quick Start Goals dismiss logic
const quickStartDismissed = ref(false)
const allQuickStartAppsInstalled = computed(() =>
QUICK_START_APPS.every((appId) => Object.keys(packages.value).includes(appId))
)
const showQuickStart = computed(() => {
if (allQuickStartAppsInstalled.value) return false
return !quickStartDismissed.value
})
function loadQuickStartState() {
try {
const raw = localStorage.getItem(QUICK_START_KEY)
if (!raw) { quickStartDismissed.value = false; return }
const data = JSON.parse(raw) as { dismissed: boolean; loginCount: number }
if (!data.dismissed) { quickStartDismissed.value = false; return }
// Re-show every N logins
const loginCount = (data.loginCount || 0) + 1
localStorage.setItem(QUICK_START_KEY, JSON.stringify({ dismissed: true, loginCount }))
quickStartDismissed.value = loginCount % QUICK_START_RESHOW_LOGINS !== 0
} catch {
quickStartDismissed.value = false
}
}
function dismissQuickStart() {
quickStartDismissed.value = true
try {
localStorage.setItem(QUICK_START_KEY, JSON.stringify({ dismissed: true, loginCount: 0 }))
} catch { /* ignore */ }
}
loadQuickStartState()
</script>
<style scoped>
@@ -356,7 +419,7 @@ const runningCount = computed(() =>
}
/* 2advanced-style card animation sequence */
.home-card {
.grid > .home-card {
min-height: 280px;
}
@@ -432,6 +495,9 @@ const runningCount = computed(() =>
opacity: 0;
transform: scale(0.5);
border-color: transparent;
min-height: 44px;
padding-top: 10px;
padding-bottom: 10px;
}
.home-card-animate .home-card-btn {
+1 -1
View File
@@ -118,7 +118,7 @@
</div>
<!-- Scrollable Apps Section -->
<div class="flex-1 overflow-y-auto pr-2 -mr-2 pb-0 md:pb-6">
<div class="flex-1 overflow-y-auto pr-2 -mr-2 pb-48">
<!-- Apps Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
+43 -27
View File
@@ -240,33 +240,38 @@
</div>
<p class="text-sm text-white/60 mb-6">Control what data the AI assistant can see. All categories are off by default.</p>
<div class="space-y-3">
<button
v-for="cat in aiCategories"
:key="cat.id"
@click="aiPermissions.toggle(cat.id)"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left"
:class="aiPermissions.isEnabled(cat.id)
? 'bg-white/10 border-orange-500/40'
: 'bg-black/20 border-white/10 hover:border-white/20'"
>
<svg class="w-5 h-5 shrink-0" :class="aiPermissions.isEnabled(cat.id) ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="cat.icon" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="aiPermissions.isEnabled(cat.id) ? 'text-white/95' : 'text-white/70'">{{ cat.label }}</p>
<p class="text-xs text-white/50 mt-0.5">{{ cat.description }}</p>
<div class="space-y-5">
<div v-for="group in aiCategoryGroups" :key="group.label">
<p class="text-xs font-medium text-white/40 uppercase tracking-wider mb-2 px-1">{{ group.label }}</p>
<div class="space-y-2">
<button
v-for="cat in group.items"
:key="cat.id"
@click="aiPermissions.toggle(cat.id)"
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left"
:class="aiPermissions.isEnabled(cat.id)
? 'bg-white/10 border-orange-500/40'
: 'bg-black/20 border-white/10 hover:border-white/20'"
>
<svg class="w-5 h-5 shrink-0" :class="aiPermissions.isEnabled(cat.id) ? 'text-orange-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="cat.icon" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium" :class="aiPermissions.isEnabled(cat.id) ? 'text-white/95' : 'text-white/70'">{{ cat.label }}</p>
<p class="text-xs text-white/50 mt-0.5">{{ cat.description }}</p>
</div>
<div
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="aiPermissions.isEnabled(cat.id) ? 'bg-orange-500' : 'bg-white/15'"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="aiPermissions.isEnabled(cat.id) ? 'translate-x-5' : 'translate-x-1'"
/>
</div>
</button>
</div>
<div
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="aiPermissions.isEnabled(cat.id) ? 'bg-orange-500' : 'bg-white/15'"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="aiPermissions.isEnabled(cat.id) ? 'translate-x-5' : 'translate-x-1'"
/>
</div>
</button>
</div>
</div>
</div>
</div>
@@ -287,7 +292,18 @@ const router = useRouter()
const store = useAppStore()
const uiMode = useUIModeStore()
const aiPermissions = useAIPermissionsStore()
const aiCategories = AI_PERMISSION_CATEGORIES
const aiCategoryGroups = computed(() => {
const groups: { label: string; items: typeof AI_PERMISSION_CATEGORIES }[] = []
for (const cat of AI_PERMISSION_CATEGORIES) {
const existing = groups.find(g => g.label === cat.group)
if (existing) {
existing.items.push(cat)
} else {
groups.push({ label: cat.group, items: [cat] })
}
}
return groups
})
const interfaceModes: { id: UIMode; label: string; description: string; iconPaths: string[] }[] = [
{