2026-03-04 11:22:15 +00:00
import { ref , readonly } from 'vue'
import { archyBridge } from '@/services/archyBridge'
type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files'
interface ArchyApp {
id : string
name : string
state : string
status : string
}
interface ArchySystemInfo {
version? : string
name? : string
}
interface ArchyNetworkInfo {
connected? : boolean
}
2026-03-04 12:15:33 +00:00
export interface ArchyWalletInfo {
balanceSats? : number
channelCount? : number
totalCapacitySats? : number
nodePubkey? : string
}
export interface ArchyFileEntry {
name : string
path : string
size? : number
modified? : string
type : 'file' | 'folder'
}
2026-03-04 11:22:15 +00:00
// Singleton reactive state (shared across all components using this composable)
const isEmbedded = ref ( false )
const isInitialized = ref ( false )
const permissions = ref < AIContextCategory [] >([])
const accentColor = ref < string | null >( null )
const installedApps = ref < ArchyApp [] >([])
const systemInfo = ref < ArchySystemInfo >({})
const networkInfo = ref < ArchyNetworkInfo >({})
2026-03-04 12:15:33 +00:00
const walletInfo = ref < ArchyWalletInfo >({})
const fileList = ref < ArchyFileEntry [] >([])
2026-03-04 11:22:15 +00:00
let cleanups : (() => void )[] = []
/**
* Reactive composable wrapping archyBridge for Archy ↔ AIUI integration.
* Call `init()` once in App.vue when `?embedded=true` is detected.
*/
export function useArchy() {
/** Initialize the bridge and start listening for Archy messages */
function init() {
if ( isInitialized . value ) return
const embedded = !! ( window as unknown as Record < string , unknown >). __AIUI_EMBEDDED__
isEmbedded . value = embedded
if ( ! embedded || ! archyBridge . isInArchy ()) return
archyBridge . init ()
isInitialized . value = true
// Listen for permission updates
const unsubPerms = archyBridge . onPermissionsUpdate (( cats ) => {
permissions . value = cats
// Auto-fetch context for newly permitted categories
fetchPermittedContext ( cats )
})
cleanups . push ( unsubPerms )
// Listen for theme updates
const unsubTheme = archyBridge . onThemeUpdate (( theme ) => {
accentColor . value = theme . accent
applyAccentColor ( theme . accent )
})
cleanups . push ( unsubTheme )
// Request theme on init
archyBridge . requestTheme ()
}
/** Fetch context for all permitted categories */
async function fetchPermittedContext ( cats : AIContextCategory []) {
const fetches : Promise < void >[] = []
if ( cats . includes ( 'apps' )) {
fetches . push (
archyBridge . requestContext ( 'apps' ). then (( res ) => {
if ( res . permitted && Array . isArray ( res . data )) {
installedApps . value = res . data as ArchyApp []
}
}). catch (() => {}),
)
}
if ( cats . includes ( 'system' )) {
fetches . push (
archyBridge . requestContext ( 'system' ). then (( res ) => {
if ( res . permitted && res . data ) {
systemInfo . value = res . data as ArchySystemInfo
}
}). catch (() => {}),
)
}
if ( cats . includes ( 'network' )) {
fetches . push (
archyBridge . requestContext ( 'network' ). then (( res ) => {
if ( res . permitted && res . data ) {
networkInfo . value = res . data as ArchyNetworkInfo
}
}). catch (() => {}),
)
}
2026-03-04 12:15:33 +00:00
if ( cats . includes ( 'wallet' )) {
fetches . push (
archyBridge . requestContext ( 'wallet' ). then (( res ) => {
if ( res . permitted && res . data ) {
walletInfo . value = res . data as ArchyWalletInfo
}
}). catch (() => {}),
)
}
if ( cats . includes ( 'files' )) {
fetches . push (
archyBridge . requestContext ( 'files' ). then (( res ) => {
if ( res . permitted && Array . isArray ( res . data )) {
fileList . value = res . data as ArchyFileEntry []
}
}). catch (() => {}),
)
}
2026-03-04 11:22:15 +00:00
await Promise . all ( fetches )
}
/** Refresh context data (call when user returns to chat) */
async function refreshContext() {
if ( ! isInitialized . value ) return
await fetchPermittedContext ( permissions . value )
}
/** Request Archy to perform an action */
async function requestAction ( action : string , params : Record < string , string > = {}) {
if ( ! isInitialized . value ) return { success : false , error : 'Not initialized' }
return archyBridge . requestAction ( action , params )
}
/** Apply accent color as CSS custom property */
function applyAccentColor ( color : string ) {
document . documentElement . style . setProperty ( '--color-accent' , color )
}
/** Build context string for AI system prompt */
function buildArchyContext () : string {
if ( ! isInitialized . value ) return ''
const sections : string [] = []
if ( permissions . value . includes ( 'apps' ) && installedApps . value . length > 0 ) {
const appList = installedApps . value
. map (( a ) => `- ${ a . name } ( ${ a . state }${ a . status ? ', ' + a . status : '' } )` )
. join ( '\n' )
sections . push ( `**Installed apps on this node:** \ n ${ appList } ` )
}
if ( permissions . value . includes ( 'system' ) && systemInfo . value . name ) {
const sys = systemInfo . value
sections . push ( `**System:** ${ sys . name }${ sys . version ? ' v' + sys . version : '' } ` )
}
if ( permissions . value . includes ( 'network' )) {
const net = networkInfo . value
sections . push ( `**Network:** ${ net . connected ? 'Connected' : 'Disconnected' } ` )
}
2026-03-04 12:15:33 +00:00
if ( permissions . value . includes ( 'wallet' ) && walletInfo . value . balanceSats !== undefined ) {
const w = walletInfo . value
const balance = w . balanceSats !
const parts = [ `Balance: ${ balance . toLocaleString () } sats` ]
if ( w . channelCount !== undefined ) parts . push ( ` ${ w . channelCount } channels` )
if ( w . totalCapacitySats !== undefined ) parts . push ( `Total capacity: ${ w . totalCapacitySats . toLocaleString () } sats` )
if ( w . nodePubkey ) parts . push ( `Pubkey: ${ w . nodePubkey . slice ( 0 , 8 ) } ...` )
sections . push ( `**Lightning Wallet:** ${ parts . join ( ' | ' ) } ` )
}
if ( permissions . value . includes ( 'files' ) && fileList . value . length > 0 ) {
const files = fileList . value
const recent = files . slice ( 0 , 20 )
const fileNames = recent . map (( f ) => f . name ). join ( ', ' )
sections . push ( `**Files:** ${ files . length } files in Nextcloud. Recent: ${ fileNames } ` )
}
2026-03-04 11:22:15 +00:00
if ( sections . length === 0 ) return ''
return ` \ n \ n**Archy Node Context** (this user is running AIUI on their Archipelago node): \ n ${ sections . join ( '\n' ) } \ n \ nYou can help the user manage their node. Available actions: open an app (open-app), install an app (install-app), navigate in Archy (navigate). When recommending apps, check if they're already installed.`
}
/** Clean up on component unmount */
function destroy() {
for ( const cleanup of cleanups ) cleanup ()
cleanups = []
archyBridge . destroy ()
isInitialized . value = false
}
return {
isEmbedded : readonly ( isEmbedded ),
isInitialized : readonly ( isInitialized ),
permissions : readonly ( permissions ),
accentColor : readonly ( accentColor ),
installedApps : readonly ( installedApps ),
systemInfo : readonly ( systemInfo ),
networkInfo : readonly ( networkInfo ),
2026-03-04 12:15:33 +00:00
walletInfo : readonly ( walletInfo ),
fileList : readonly ( fileList ),
2026-03-04 11:22:15 +00:00
init ,
destroy ,
refreshContext ,
requestAction ,
buildArchyContext ,
}
}