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:
@@ -77,12 +77,14 @@ import { useCLIStore } from '@/stores/cli'
|
||||
import { useMessageToast } from '@/composables/useMessageToast'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useScreensaverStore } from '@/stores/screensaver'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
|
||||
const router = useRouter()
|
||||
const screensaverStore = useScreensaverStore()
|
||||
const spotlightStore = useSpotlightStore()
|
||||
const cliStore = useCLIStore()
|
||||
const appStore = useAppStore()
|
||||
const uiModeStore = useUIModeStore()
|
||||
const messageToast = useMessageToast()
|
||||
const toastMessage = messageToast.toastMessage
|
||||
|
||||
@@ -125,6 +127,19 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
cliStore.toggle()
|
||||
return
|
||||
}
|
||||
// Cmd+1/2/3 - switch UI mode (skip when in input)
|
||||
if (mod && !isInput && appStore.isAuthenticated) {
|
||||
if (e.key === '1') { e.preventDefault(); uiModeStore.setMode('easy'); router.push('/dashboard'); return }
|
||||
if (e.key === '2') { e.preventDefault(); uiModeStore.setMode('gamer'); router.push('/dashboard'); return }
|
||||
if (e.key === '3') { e.preventDefault(); router.push('/dashboard/chat'); return }
|
||||
}
|
||||
// Cmd+M / Ctrl+M - cycle UI mode (skip when in input)
|
||||
if (mod && (e.key === 'm' || e.key === 'M') && !isInput && appStore.isAuthenticated) {
|
||||
e.preventDefault()
|
||||
uiModeStore.cycleMode()
|
||||
router.push('/dashboard')
|
||||
return
|
||||
}
|
||||
// 's' key activates screensaver when authenticated (skip if typing in input)
|
||||
if (e.key === 's' || e.key === 'S') {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
<template>
|
||||
<div class="mode-switcher">
|
||||
<!-- Compact mode: small pill for chat fullscreen -->
|
||||
<div v-if="compact" class="chat-mode-pill-inner" @click="handleCompactClick">
|
||||
<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="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<span class="text-xs font-medium">{{ currentLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Full mode switcher -->
|
||||
<div v-else class="mode-switcher">
|
||||
<button
|
||||
v-for="m in modes"
|
||||
:key="m.id"
|
||||
@@ -13,14 +22,30 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import type { UIMode } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const uiMode = useUIModeStore()
|
||||
const router = useRouter()
|
||||
|
||||
const modes: { id: UIMode; label: string }[] = [
|
||||
{ id: 'easy', label: 'Easy' },
|
||||
{ id: 'gamer', label: 'Pro' },
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
]
|
||||
|
||||
const currentLabel = computed(() => {
|
||||
const found = modes.find(m => m.id === uiMode.mode)
|
||||
return found ? found.label : 'Pro'
|
||||
})
|
||||
|
||||
function handleCompactClick() {
|
||||
const newMode = uiMode.cycleMode()
|
||||
router.push(newMode === 'chat' ? '/dashboard/chat' : '/dashboard')
|
||||
}
|
||||
</script>
|
||||
|
||||
248
neode-ui/src/services/contextBroker.ts
Normal file
248
neode-ui/src/services/contextBroker.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type {
|
||||
AIUIRequest,
|
||||
ArchyResponse,
|
||||
AIContextCategory,
|
||||
ArchyContextResponse,
|
||||
ArchyActionResponse,
|
||||
} from '@/types/aiui-protocol'
|
||||
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
/**
|
||||
* Context Broker — mediates all communication between AIUI (iframe) and Archy.
|
||||
*
|
||||
* AIUI sends context/action requests via postMessage.
|
||||
* The broker checks permissions, fetches data from Pinia stores,
|
||||
* sanitizes it (strips sensitive fields), and responds.
|
||||
*/
|
||||
export class ContextBroker {
|
||||
private iframe: Ref<HTMLIFrameElement | null>
|
||||
private allowedOrigin: string
|
||||
private listener: ((e: MessageEvent) => void) | null = null
|
||||
|
||||
constructor(iframe: Ref<HTMLIFrameElement | null>, aiuiUrl: string) {
|
||||
this.iframe = iframe
|
||||
// Extract origin from URL for security validation
|
||||
try {
|
||||
const url = new URL(aiuiUrl, window.location.origin)
|
||||
this.allowedOrigin = url.origin
|
||||
} catch {
|
||||
this.allowedOrigin = window.location.origin
|
||||
}
|
||||
}
|
||||
|
||||
/** Start listening for postMessage events from AIUI */
|
||||
start() {
|
||||
this.listener = (e: MessageEvent) => this.handleMessage(e)
|
||||
window.addEventListener('message', this.listener)
|
||||
}
|
||||
|
||||
/** Stop listening and clean up */
|
||||
stop() {
|
||||
if (this.listener) {
|
||||
window.removeEventListener('message', this.listener)
|
||||
this.listener = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Send permissions update to AIUI so it knows what it can ask for */
|
||||
sendPermissionsUpdate() {
|
||||
const perms = useAIPermissionsStore()
|
||||
this.postToIframe({
|
||||
type: 'permissions:update',
|
||||
categories: perms.enabledCategories,
|
||||
})
|
||||
}
|
||||
|
||||
/** Send theme info to AIUI */
|
||||
sendTheme() {
|
||||
this.postToIframe({
|
||||
type: 'theme:response',
|
||||
theme: {
|
||||
accent: '#fb923c',
|
||||
mode: 'dark',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent) {
|
||||
// Security: verify origin
|
||||
if (event.origin !== this.allowedOrigin) return
|
||||
|
||||
const msg = event.data as AIUIRequest
|
||||
if (!msg || typeof msg.type !== 'string') return
|
||||
|
||||
switch (msg.type) {
|
||||
case 'ready':
|
||||
this.sendPermissionsUpdate()
|
||||
this.sendTheme()
|
||||
break
|
||||
|
||||
case 'context:request':
|
||||
this.handleContextRequest(msg.id, msg.category, msg.query)
|
||||
break
|
||||
|
||||
case 'action:request':
|
||||
this.handleActionRequest(msg.id, msg.action, msg.params)
|
||||
break
|
||||
|
||||
case 'theme:request':
|
||||
this.sendTheme()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private handleContextRequest(id: string, category: AIContextCategory, query?: string) {
|
||||
const perms = useAIPermissionsStore()
|
||||
|
||||
if (!perms.isEnabled(category)) {
|
||||
this.postToIframe({
|
||||
type: 'context:response',
|
||||
id,
|
||||
data: null,
|
||||
permitted: false,
|
||||
} satisfies ArchyContextResponse)
|
||||
return
|
||||
}
|
||||
|
||||
const data = this.fetchAndSanitize(category, query)
|
||||
this.postToIframe({
|
||||
type: 'context:response',
|
||||
id,
|
||||
data,
|
||||
permitted: true,
|
||||
} satisfies ArchyContextResponse)
|
||||
}
|
||||
|
||||
private handleActionRequest(id: string, action: string, params: Record<string, string>) {
|
||||
const appStore = useAppStore()
|
||||
let success = false
|
||||
let error: string | undefined
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'navigate':
|
||||
if (params.path) {
|
||||
window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: params.path }))
|
||||
success = true
|
||||
} else {
|
||||
error = 'Missing path parameter'
|
||||
}
|
||||
break
|
||||
|
||||
case 'open-app':
|
||||
if (params.appId) {
|
||||
window.dispatchEvent(new CustomEvent('aiui:open-app', { detail: params.appId }))
|
||||
success = true
|
||||
} else {
|
||||
error = 'Missing appId parameter'
|
||||
}
|
||||
break
|
||||
|
||||
case 'install-app':
|
||||
if (params.appId && params.marketplaceUrl && params.version) {
|
||||
appStore.installPackage(params.appId, params.marketplaceUrl, params.version).then(() => {
|
||||
this.postToIframe({
|
||||
type: 'action:response',
|
||||
id,
|
||||
success: true,
|
||||
} satisfies ArchyActionResponse)
|
||||
}).catch((err: Error) => {
|
||||
this.postToIframe({
|
||||
type: 'action:response',
|
||||
id,
|
||||
success: false,
|
||||
error: err.message,
|
||||
} satisfies ArchyActionResponse)
|
||||
})
|
||||
return // async — response sent in promise callbacks
|
||||
}
|
||||
error = 'Missing appId parameter'
|
||||
break
|
||||
|
||||
default:
|
||||
error = `Unknown action: ${action}`
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error'
|
||||
}
|
||||
|
||||
this.postToIframe({
|
||||
type: 'action:response',
|
||||
id,
|
||||
success,
|
||||
error,
|
||||
} satisfies ArchyActionResponse)
|
||||
}
|
||||
|
||||
/** Fetch data from stores and strip sensitive fields */
|
||||
private fetchAndSanitize(category: AIContextCategory, _query?: string): unknown {
|
||||
const appStore = useAppStore()
|
||||
|
||||
switch (category) {
|
||||
case 'apps':
|
||||
return this.sanitizeApps(appStore)
|
||||
case 'system':
|
||||
return this.sanitizeSystem(appStore)
|
||||
case 'network':
|
||||
return this.sanitizeNetwork(appStore)
|
||||
case 'wallet':
|
||||
return this.sanitizeWallet(appStore)
|
||||
case 'files':
|
||||
return this.sanitizeFiles(appStore)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeApps(store: ReturnType<typeof useAppStore>): unknown {
|
||||
const packages = store.packages || {}
|
||||
return Object.entries(packages).map(([id, pkg]) => ({
|
||||
id,
|
||||
name: pkg.manifest?.title || id,
|
||||
state: pkg.state || 'unknown',
|
||||
status: pkg.installed?.status || 'unknown',
|
||||
}))
|
||||
}
|
||||
|
||||
private sanitizeSystem(store: ReturnType<typeof useAppStore>): unknown {
|
||||
const info = store.serverInfo
|
||||
if (!info) return { status: 'unavailable' }
|
||||
return {
|
||||
version: info.version,
|
||||
name: info.name,
|
||||
// Omit: hostname, IP, paths, kernel version, pubkey
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeNetwork(store: ReturnType<typeof useAppStore>): unknown {
|
||||
return {
|
||||
connected: store.isConnected,
|
||||
// Omit: IP addresses, ports, peer details
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeWallet(_store: ReturnType<typeof useAppStore>): unknown {
|
||||
// Wallet data requires careful handling — only expose aggregates
|
||||
return {
|
||||
available: false,
|
||||
message: 'Wallet context not yet implemented',
|
||||
// Will integrate with LND store when available
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeFiles(_store: ReturnType<typeof useAppStore>): unknown {
|
||||
// File listing requires cloud store integration
|
||||
return {
|
||||
available: false,
|
||||
message: 'File context not yet implemented',
|
||||
// Will integrate with cloud store when available
|
||||
}
|
||||
}
|
||||
|
||||
private postToIframe(msg: ArchyResponse) {
|
||||
if (!this.iframe.value?.contentWindow) return
|
||||
this.iframe.value.contentWindow.postMessage(msg, this.allowedOrigin)
|
||||
}
|
||||
}
|
||||
106
neode-ui/src/stores/aiPermissions.ts
Normal file
106
neode-ui/src/stores/aiPermissions.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { AIContextCategory } from '@/types/aiui-protocol'
|
||||
|
||||
const STORAGE_KEY = 'archipelago-ai-permissions'
|
||||
|
||||
export interface AIPermissionCategory {
|
||||
id: AIContextCategory
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const AI_PERMISSION_CATEGORIES: AIPermissionCategory[] = [
|
||||
{
|
||||
id: 'apps',
|
||||
label: 'Installed Apps',
|
||||
description: 'App names, status, and health — no credentials or config details',
|
||||
icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z',
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: 'System Stats',
|
||||
description: 'CPU, RAM, disk usage — no file paths or IP addresses',
|
||||
icon: 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z',
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
label: 'Network Status',
|
||||
description: 'Connection status, peer count — no IP addresses or keys',
|
||||
icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01',
|
||||
},
|
||||
{
|
||||
id: 'wallet',
|
||||
label: 'Wallet Overview',
|
||||
description: 'Balance, channel count — no private keys, seeds, or addresses',
|
||||
icon: 'M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'File Names',
|
||||
description: 'Folder and file names in Cloud — no file contents',
|
||||
icon: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
|
||||
},
|
||||
]
|
||||
|
||||
export const useAIPermissionsStore = defineStore('aiPermissions', () => {
|
||||
const enabled = ref<Set<AIContextCategory>>(loadFromStorage())
|
||||
|
||||
function loadFromStorage(): Set<AIContextCategory> {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as AIContextCategory[]
|
||||
return new Set(parsed.filter(c => AI_PERMISSION_CATEGORIES.some(cat => cat.id === c)))
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return new Set()
|
||||
}
|
||||
|
||||
function save() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...enabled.value]))
|
||||
}
|
||||
|
||||
function isEnabled(category: AIContextCategory): boolean {
|
||||
return enabled.value.has(category)
|
||||
}
|
||||
|
||||
function toggle(category: AIContextCategory) {
|
||||
if (enabled.value.has(category)) {
|
||||
enabled.value.delete(category)
|
||||
} else {
|
||||
enabled.value.add(category)
|
||||
}
|
||||
// Trigger reactivity
|
||||
enabled.value = new Set(enabled.value)
|
||||
save()
|
||||
}
|
||||
|
||||
function enableAll() {
|
||||
enabled.value = new Set(AI_PERMISSION_CATEGORIES.map(c => c.id))
|
||||
save()
|
||||
}
|
||||
|
||||
function disableAll() {
|
||||
enabled.value = new Set()
|
||||
save()
|
||||
}
|
||||
|
||||
const enabledCategories = computed(() => [...enabled.value])
|
||||
const allEnabled = computed(() => enabled.value.size === AI_PERMISSION_CATEGORIES.length)
|
||||
const noneEnabled = computed(() => enabled.value.size === 0)
|
||||
|
||||
return {
|
||||
enabled,
|
||||
isEnabled,
|
||||
toggle,
|
||||
enableAll,
|
||||
disableAll,
|
||||
enabledCategories,
|
||||
allEnabled,
|
||||
noneEnabled,
|
||||
}
|
||||
})
|
||||
@@ -25,9 +25,17 @@ export const useUIModeStore = defineStore('uiMode', () => {
|
||||
localStorage.setItem(STORAGE_KEY, newMode)
|
||||
}
|
||||
|
||||
function cycleMode(): UIMode {
|
||||
const order: UIMode[] = ['easy', 'gamer']
|
||||
const idx = order.indexOf(mode.value)
|
||||
const next = order[(idx >= 0 ? idx + 1 : 0) % order.length] as UIMode
|
||||
setMode(next)
|
||||
return next
|
||||
}
|
||||
|
||||
const isGamer = computed(() => mode.value === 'gamer')
|
||||
const isEasy = computed(() => mode.value === 'easy')
|
||||
const isChat = computed(() => mode.value === 'chat')
|
||||
|
||||
return { mode, setMode, syncFromBackend, isGamer, isEasy, isChat }
|
||||
return { mode, setMode, cycleMode, syncFromBackend, isGamer, isEasy, isChat }
|
||||
})
|
||||
|
||||
@@ -101,6 +101,109 @@
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Chat launcher button — sidebar (desktop) */
|
||||
.chat-launcher-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.chat-launcher-btn:hover {
|
||||
background: rgba(251, 146, 60, 0.15);
|
||||
border-color: rgba(251, 146, 60, 0.3);
|
||||
color: #fb923c;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Chat launcher button — mobile bottom bar */
|
||||
.chat-launcher-btn-mobile {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.chat-launcher-btn-mobile:hover {
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
/* Chat close button (floating pill) */
|
||||
.chat-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-close-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Chat fullscreen layout — fills the view-wrapper container */
|
||||
.chat-fullscreen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-mode-pill {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.chat-iframe {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Chat placeholder (no AIUI URL) */
|
||||
.chat-placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-placeholder-inner {
|
||||
text-align: center;
|
||||
max-width: 28rem;
|
||||
padding: 3rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.chat-placeholder-icon {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Goal cards */
|
||||
.goal-card {
|
||||
cursor: pointer;
|
||||
|
||||
87
neode-ui/src/types/aiui-protocol.ts
Normal file
87
neode-ui/src/types/aiui-protocol.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* AIUI ↔ Archy postMessage Protocol
|
||||
*
|
||||
* AIUI (iframe) communicates with Archy (host) via structured messages.
|
||||
* Archy acts as a context broker — AIUI never directly accesses node data.
|
||||
*/
|
||||
|
||||
/** Data categories that AIUI can request access to */
|
||||
export type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files'
|
||||
|
||||
/** Actions AIUI can request Archy to perform */
|
||||
export type AIActionType = 'install-app' | 'open-app' | 'navigate'
|
||||
|
||||
// ─── AIUI → Archy (Requests) ───────────────────────────────────────────────
|
||||
|
||||
export interface AIUIContextRequest {
|
||||
type: 'context:request'
|
||||
id: string
|
||||
category: AIContextCategory
|
||||
query?: string
|
||||
}
|
||||
|
||||
export interface AIUIActionRequest {
|
||||
type: 'action:request'
|
||||
id: string
|
||||
action: AIActionType
|
||||
params: Record<string, string>
|
||||
}
|
||||
|
||||
export interface AIUIReadyMessage {
|
||||
type: 'ready'
|
||||
}
|
||||
|
||||
export interface AIUIThemeRequest {
|
||||
type: 'theme:request'
|
||||
}
|
||||
|
||||
export type AIUIRequest =
|
||||
| AIUIContextRequest
|
||||
| AIUIActionRequest
|
||||
| AIUIReadyMessage
|
||||
| AIUIThemeRequest
|
||||
|
||||
// ─── Archy → AIUI (Responses) ──────────────────────────────────────────────
|
||||
|
||||
export interface ArchyContextResponse {
|
||||
type: 'context:response'
|
||||
id: string
|
||||
data: unknown
|
||||
permitted: boolean
|
||||
}
|
||||
|
||||
export interface ArchyActionResponse {
|
||||
type: 'action:response'
|
||||
id: string
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ArchyThemeResponse {
|
||||
type: 'theme:response'
|
||||
theme: {
|
||||
accent: string
|
||||
mode: 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
export interface ArchyPermissionsUpdate {
|
||||
type: 'permissions:update'
|
||||
categories: AIContextCategory[]
|
||||
}
|
||||
|
||||
export type ArchyResponse =
|
||||
| ArchyContextResponse
|
||||
| ArchyActionResponse
|
||||
| ArchyThemeResponse
|
||||
| ArchyPermissionsUpdate
|
||||
|
||||
// ─── All messages ───────────────────────────────────────────────────────────
|
||||
|
||||
export type AIUIMessage = AIUIRequest | ArchyResponse
|
||||
|
||||
/** Protocol version for compatibility checks */
|
||||
export const AIUI_PROTOCOL_VERSION = '1.0.0'
|
||||
|
||||
/** Message origin prefix used for validation */
|
||||
export const AIUI_MESSAGE_PREFIX = 'aiui:'
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[] }[] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user