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:
co-authored by
Claude Opus 4.6
parent
7b044d22ef
commit
584ce646e1
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user