feat: AIUI chat mode integration with iframe, context broker, overnight loop

- Chat mode: AIUI loads in sandboxed iframe at /dashboard/chat with transparent bg
- Mode switcher: Easy + Pro tabs only, Chat is a launcher button
- Keyboard shortcuts: Cmd+1 (Easy), Cmd+2 (Pro), Cmd+3 (Chat), Cmd+M (cycle)
- Directional transitions: chat slides from/to left, dashboard from/to right
- Context broker: postMessage protocol for quarantined AIUI communication
- AI permissions store: user-controlled toggles for data access categories
- Settings UI: AI Data Access section with per-category toggles
- AIUI container manifest and nginx proxy config for /aiui/
- Deploy script builds AIUI with /aiui/ base path
- Overnight loop infrastructure (loop.sh, prepare.sh, plan.md, prompt.md)
- Security hooks for autonomous overnight runs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 12:06:20 +00:00
co-authored by Claude Opus 4.6
parent 7b044d22ef
commit 584ce646e1
23 changed files with 1528 additions and 77 deletions
+73 -20
View File
@@ -1,29 +1,82 @@
<template>
<div class="flex flex-col items-center justify-center min-h-[60vh]">
<div class="glass-card p-12 max-w-lg w-full text-center">
<div class="w-16 h-16 mx-auto mb-6 rounded-full bg-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
<div class="chat-fullscreen">
<!-- Close button: top-left, glass pill, returns to previous view -->
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="text-xs font-medium">Close</span>
</button>
</div>
<!-- AIUI iframe -->
<iframe
v-if="aiuiUrl"
ref="aiuiFrame"
:src="aiuiUrl"
class="chat-iframe"
sandbox="allow-scripts allow-same-origin allow-forms"
allow="microphone"
style="background: transparent"
/>
<!-- Fallback when no AIUI URL configured -->
<div v-else class="chat-placeholder">
<div class="chat-placeholder-inner">
<div class="chat-placeholder-icon">
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<h2 class="text-2xl font-semibold text-white mb-2">AI Assistant</h2>
<p class="text-white/60 mb-4 leading-relaxed">
AIUI is not connected. Configure the AIUI URL in your environment settings.
</p>
<p class="text-xs text-white/30">
Set <code class="text-white/50">VITE_AIUI_URL</code> or deploy the AIUI container.
</p>
</div>
<h2 class="text-2xl font-semibold text-white mb-2">AI Assistant</h2>
<p class="text-white/60 mb-8 leading-relaxed">
Conversational interface coming soon. Talk to your node, ask questions,
and manage everything through natural language.
</p>
<div class="bg-white/5 border border-white/10 rounded-xl p-4">
<input
type="text"
disabled
placeholder="What would you like to do?"
class="w-full bg-transparent text-white/30 outline-none placeholder-white/30 cursor-not-allowed"
/>
</div>
<p class="text-xs text-white/30 mt-4">AIUI integration in development</p>
</div>
</div>
</template>
<script setup lang="ts">
// Chat mode placeholder — will integrate AIUI here
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { ContextBroker } from '@/services/contextBroker'
const router = useRouter()
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
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 {
router.push('/dashboard')
}
}
onMounted(() => {
// Start context broker if AIUI URL is available
if (aiuiUrl.value) {
broker = new ContextBroker(aiuiFrame, aiuiUrl.value)
broker.start()
}
})
onBeforeUnmount(() => {
broker?.stop()
broker = null
})
</script>
+132 -43
View File
@@ -34,16 +34,7 @@
class="bg-glitch-scan"
:class="{ 'glitch-active': isGlitching }"
/>
<!-- Continuous glitch/flash overlays - same as login, every 5s -->
<div
class="dashboard-glitch-layer dashboard-glitch-1 bg-fullwidth"
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
/>
<div
class="dashboard-glitch-layer dashboard-glitch-2 bg-fullwidth"
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
/>
<div class="dashboard-glitch-scan" />
<!-- Glitch overlays removed only intro glitch plays (via isGlitching) -->
</div>
<!-- Oomph accent - brief impact flash when dashboard loads -->
@@ -68,6 +59,7 @@
<!-- Sidebar - Desktop Only, animates in at end with separate parts -->
<aside
v-show="!chatFullscreen"
data-controller-zone="sidebar"
class="hidden md:flex w-[256px] flex-shrink-0 relative flex-col z-10"
:class="{ 'sidebar-animate': showZoomIn }"
@@ -82,11 +74,7 @@
</div>
</div>
<div class="px-6 pt-4 pb-2 shrink-0">
<ModeSwitcher />
</div>
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-2">
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4">
<RouterLink
v-for="(item, idx) in desktopNavItems"
:key="item.path"
@@ -96,24 +84,30 @@
:style="{ '--nav-stagger': idx }"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getIconPath(item.icon)"
<path
v-for="(path, index) in getIconPath(item.icon)"
:key="index"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
/>
</svg>
<span>{{ item.label }}</span>
</RouterLink>
</nav>
<div class="sidebar-controller px-6 pb-2 shrink-0">
<ControllerIndicator />
</div>
<!-- Chat launcher button -->
<button
@click="router.push('/dashboard/chat')"
class="chat-launcher-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
</svg>
<span>Chat</span>
</button>
<div class="sidebar-logout p-6 shrink-0">
<!-- Logout - styled as nav item, below Settings -->
<button
@click="handleLogout"
class="sidebar-logout-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
@@ -123,11 +117,22 @@
</svg>
<span>Logout</span>
</button>
</nav>
<div class="sidebar-controller px-6 pb-2 shrink-0">
<ControllerIndicator />
</div>
<!-- Online status pill - bottom of sidebar (desktop only; sidebar is hidden on mobile) -->
<!-- Online status -->
<div class="px-6 pb-2 shrink-0">
<div class="rounded-lg bg-white/5 border border-white/10 px-4 py-2.5">
<OnlineStatusPill />
</div>
</div>
<!-- Mode switcher -->
<div class="px-6 pb-6 shrink-0">
<OnlineStatusPill />
<ModeSwitcher />
</div>
</div>
</div>
@@ -256,10 +261,17 @@
<Transition :name="getTransitionName(route)">
<div :key="route.path" class="view-wrapper">
<div
v-if="route.path === '/dashboard/chat'"
class="h-full"
>
<component :is="Component" />
</div>
<div
v-else
:class="[
'px-4 pt-4 pb-4 md:px-8 md:pt-8 md:pb-8 overflow-y-auto h-full',
needsMobileBackButtonSpace
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)] md:pb-8'
'px-4 pt-4 pb-28 md:px-8 md:pt-8 md:pb-24 overflow-y-auto h-full',
needsMobileBackButtonSpace
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)] md:pb-24'
: undefined
]"
>
@@ -274,6 +286,7 @@
<!-- Mobile Bottom Tab Bar - glass piece 5 -->
<nav
v-show="!chatFullscreen"
ref="mobileTabBar"
data-mobile-tab-bar
class="md:hidden fixed bottom-0 left-0 right-0 border-t border-glass-border shadow-glass z-50 glass-piece"
@@ -287,7 +300,7 @@
:to="item.path"
class="flex items-center justify-center w-full py-3 rounded-lg text-white/70 transition-all duration-300 relative z-10"
:class="{
'nav-tab-active': item.isCombined
'nav-tab-active': item.isCombined
? (item.path === '/dashboard/apps'
? (route.path.includes('/apps') || route.path.includes('/marketplace'))
: (route.path.includes('/cloud') || route.path.includes('/server')))
@@ -296,16 +309,25 @@
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
>
<svg class="w-7 h-7 transition-all duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getIconPath(item.icon)"
<path
v-for="(path, index) in getIconPath(item.icon)"
:key="index"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
/>
</svg>
</RouterLink>
<!-- Chat launcher -->
<button
@click="router.push('/dashboard/chat')"
class="chat-launcher-btn-mobile flex items-center justify-center w-full py-3 rounded-lg transition-all duration-300 relative z-10"
>
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
</svg>
</button>
</div>
</nav>
</div>
@@ -325,6 +347,10 @@ import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
const uiMode = useUIModeStore()
// Chat fullscreen: hide sidebar + mobile nav when on /dashboard/chat (any mode)
const chatFullscreen = computed(() => route.path === '/dashboard/chat')
const router = useRouter()
const route = useRoute()
const store = useAppStore()
@@ -357,6 +383,7 @@ const ROUTE_BACKGROUNDS: Record<string, string> = {
'/dashboard/server': 'bg-network.jpg',
'/dashboard/web5': 'bg-web5.jpg',
'/dashboard/settings': 'bg-settings.jpg',
'/dashboard/chat': 'bg-home.jpg',
}
const backgroundImage = computed(() => {
@@ -545,13 +572,12 @@ const gamerDesktopNav: NavItem[] = [
const easyDesktopNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'My Services', icon: 'apps' },
{ path: '/dashboard/apps', label: 'My Apps', icon: 'apps' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
const chatDesktopNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/chat', label: 'Chat', icon: 'chat' },
{ path: '/dashboard/apps', label: 'My Apps', icon: 'apps' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
@@ -566,19 +592,17 @@ const gamerMobileNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps', isCombined: true },
{ path: '/dashboard/cloud', label: 'Network', icon: 'server', isCombined: true },
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
const easyMobileNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'Services', icon: 'apps' },
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
const chatMobileNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/chat', label: 'Chat', icon: 'chat' },
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
@@ -628,6 +652,7 @@ const tabOrder = [
'/dashboard/cloud',
'/dashboard/server',
'/dashboard/web5',
'/dashboard/chat',
'/dashboard/settings'
]
@@ -640,6 +665,18 @@ function getTransitionName(currentRoute: any) {
previousPath = currentPath
return 'fade'
}
// Chat transitions: directional slide (chat from/to left, dashboard from/to right)
const isChat = currentPath === '/dashboard/chat'
const wasChat = previousPath === '/dashboard/chat'
if (isChat) {
previousPath = currentPath
return 'chat-open'
}
if (wasChat) {
previousPath = currentPath
return 'chat-close'
}
const isAppDetails = currentPath.includes('/apps/') && !currentPath.endsWith('/apps')
const isAppsList = currentPath === '/dashboard/apps'
@@ -1210,6 +1247,58 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
}
}
/* Chat open transition — chat slides in from left, dashboard slides out to right */
.chat-open-enter-active.view-wrapper,
.chat-open-leave-active.view-wrapper {
transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1), transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.chat-open-enter-from.view-wrapper {
opacity: 0;
transform: translateX(-60px) scale(0.96);
}
.chat-open-enter-to.view-wrapper {
opacity: 1;
transform: translateX(0) scale(1);
}
.chat-open-leave-from.view-wrapper {
opacity: 1;
transform: translateX(0) scale(1);
}
.chat-open-leave-to.view-wrapper {
opacity: 0;
transform: translateX(60px) scale(0.96);
}
/* Chat close transition — chat slides out to left, dashboard slides in from right */
.chat-close-enter-active.view-wrapper,
.chat-close-leave-active.view-wrapper {
transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1), transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.chat-close-enter-from.view-wrapper {
opacity: 0;
transform: translateX(60px) scale(0.96);
}
.chat-close-enter-to.view-wrapper {
opacity: 1;
transform: translateX(0) scale(1);
}
.chat-close-leave-from.view-wrapper {
opacity: 1;
transform: translateX(0) scale(1);
}
.chat-close-leave-to.view-wrapper {
opacity: 0;
transform: translateX(-60px) scale(0.96);
}
/* Fade transition for initial loads and default cases */
.fade-enter-active,
.fade-leave-active {
+53 -9
View File
@@ -217,15 +217,56 @@
</div>
</div>
<!-- System Section -->
<div class="path-option-card cursor-default px-6 py-6">
<h2 class="text-xl font-semibold text-white/96 mb-4">System</h2>
<div class="text-center py-8">
<svg class="w-12 h-12 mx-auto text-white/40 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p class="text-white/70">Additional settings coming soon</p>
<!-- AI Data Access Section -->
<div class="path-option-card cursor-default px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-2">
<h2 class="text-xl font-semibold text-white/96">AI Data Access</h2>
<div class="flex items-center gap-2">
<button
v-if="!aiPermissions.allEnabled"
@click="aiPermissions.enableAll()"
class="text-xs text-white/50 hover:text-white/80 transition-colors"
>
Enable All
</button>
<button
v-if="!aiPermissions.noneEnabled"
@click="aiPermissions.disableAll()"
class="text-xs text-white/50 hover:text-white/80 transition-colors"
>
Disable All
</button>
</div>
</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>
<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>
@@ -236,6 +277,7 @@ import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useUIModeStore } from '@/stores/uiMode'
import { useAIPermissionsStore, AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
import ControllerIndicator from '@/components/ControllerIndicator.vue'
import { rpcClient } from '@/api/rpc-client'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
@@ -244,6 +286,8 @@ import type { UIMode } from '@/types/api'
const router = useRouter()
const store = useAppStore()
const uiMode = useUIModeStore()
const aiPermissions = useAIPermissionsStore()
const aiCategories = AI_PERMISSION_CATEGORIES
const interfaceModes: { id: UIMode; label: string; description: string; iconPaths: string[] }[] = [
{