2026-03-04 11:22:15 +00:00
import { ref , readonly } from 'vue'
import { archyBridge } from '@/services/archyBridge'
2026-07-30 20:04:45 -04:00
import { useTheme } from '@/composables/useTheme'
2026-03-07 23:39:41 +00:00
import {
mockArchyApps , mockArchySystem , mockArchyNetwork ,
mockArchyWallet , mockArchyBitcoin , mockArchyFiles ,
} from '@/mocks/archy'
2026-03-04 11:22:15 +00:00
2026-03-06 00:56:39 +00:00
type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files' | 'bitcoin'
2026-03-04 11:22:15 +00:00
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 {
2026-03-06 00:56:39 +00:00
available? : boolean
status? : string
alias? : string
num_active_channels? : number
num_peers? : number
synced_to_chain? : boolean
block_height? : number
balance_sats? : number
channel_balance_sats? : number
pending_open_balance? : number
message? : string
2026-03-04 12:15:33 +00:00
}
export interface ArchyFileEntry {
name : string
path : string
size? : number
modified? : string
type : 'file' | 'folder'
}
2026-03-06 00:56:39 +00:00
export interface ArchyBitcoinInfo {
available : boolean
block_height? : number
sync_progress? : number
chain? : string
mempool_tx_count? : number
mempool_size? : number
}
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-06 00:56:39 +00:00
const bitcoinInfo = ref < ArchyBitcoinInfo >({ available : false })
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
2026-03-07 23:39:41 +00:00
// Dev mock mode: load realistic Archy data for standalone testing
const useMock = import . meta . env . VITE_MOCK_ARCHY === 'true' ||
new URLSearchParams ( window . location . search ). has ( 'mockArchy' )
if ( useMock && ! embedded ) {
isInitialized . value = true
isEmbedded . value = true
permissions . value = [ 'apps' , 'system' , 'network' , 'wallet' , 'bitcoin' , 'files' ]
installedApps . value = mockArchyApps as unknown as ArchyApp []
systemInfo . value = mockArchySystem
networkInfo . value = mockArchyNetwork
walletInfo . value = mockArchyWallet
bitcoinInfo . value = mockArchyBitcoin
fileList . value = mockArchyFiles
console . log ( '[AIUI] Mock Archy data loaded for dev testing' )
return
}
2026-03-04 11:22:15 +00:00
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 )
2026-07-30 20:04:45 -04:00
// Listen for theme updates — Archy always reports mode:'dark' today, but
// honor whatever it sends rather than hardcoding that assumption here;
// App.vue's mount-time isEmbeddedFlag check already forces dark
// immediately without waiting for this round trip, so this is a
// corroborating update for whenever it does arrive.
2026-03-04 11:22:15 +00:00
const unsubTheme = archyBridge . onThemeUpdate (( theme ) => {
accentColor . value = theme . accent
applyAccentColor ( theme . accent )
2026-07-30 20:04:45 -04:00
useTheme (). setTheme ( theme . mode )
2026-03-04 11:22:15 +00:00
})
cleanups . push ( unsubTheme )
// Request theme on init
archyBridge . requestTheme ()
}
/** Fetch context for all permitted categories */
async function fetchPermittedContext ( cats : AIContextCategory []) {
const fetches : Promise < void >[] = []
2026-03-07 23:39:41 +00:00
function fetchCategory < T >( cat : AIContextCategory , setter : ( data : T ) => void , validator : ( data : unknown ) => boolean = () => true ) {
return archyBridge . requestContext ( cat ). then (( res ) => {
if ( ! res . permitted ) {
console . warn ( `[AIUI Archy] ${ cat } : not permitted — user should enable in Archy Settings` )
return
}
if ( res . data && validator ( res . data )) {
setter ( res . data as T )
}
}). catch (( err ) => {
console . warn ( `[AIUI Archy] ${ cat } fetch failed:` , err ? . message ?? err )
})
}
2026-03-04 11:22:15 +00:00
if ( cats . includes ( 'apps' )) {
2026-03-07 23:39:41 +00:00
fetches . push ( fetchCategory ( 'apps' , ( data ) => { installedApps . value = data as ArchyApp [] }, Array . isArray ))
2026-03-04 11:22:15 +00:00
}
if ( cats . includes ( 'system' )) {
2026-03-07 23:39:41 +00:00
fetches . push ( fetchCategory ( 'system' , ( data ) => { systemInfo . value = data as ArchySystemInfo }))
2026-03-04 11:22:15 +00:00
}
if ( cats . includes ( 'network' )) {
2026-03-07 23:39:41 +00:00
fetches . push ( fetchCategory ( 'network' , ( data ) => { networkInfo . value = data as ArchyNetworkInfo }))
2026-03-04 11:22:15 +00:00
}
2026-03-04 12:15:33 +00:00
if ( cats . includes ( 'wallet' )) {
2026-03-07 23:39:41 +00:00
fetches . push ( fetchCategory ( 'wallet' , ( data ) => { walletInfo . value = data as ArchyWalletInfo }))
2026-03-04 12:15:33 +00:00
}
2026-03-06 00:56:39 +00:00
if ( cats . includes ( 'bitcoin' )) {
2026-03-07 23:39:41 +00:00
fetches . push ( fetchCategory ( 'bitcoin' , ( data ) => { bitcoinInfo . value = data as ArchyBitcoinInfo }))
2026-03-06 00:56:39 +00:00
}
2026-03-04 12:15:33 +00:00
if ( cats . includes ( 'files' )) {
2026-03-07 23:39:41 +00:00
fetches . push ( fetchCategory ( 'files' , ( data ) => { fileList . value = data as ArchyFileEntry [] }, Array . isArray ))
2026-03-04 12:15:33 +00:00
}
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 )
}
2026-03-06 00:56:39 +00:00
/** Read a file's text content via FileBrowser */
async function readFile ( path : string ) : Promise < { content : string ; truncated : boolean ; size : number } | null > {
const res = await requestAction ( 'read-file' , { path })
const data = ( res as unknown as Record < string , unknown >). data
if ( res . success && data ) {
return data as { content : string ; truncated : boolean ; size : number }
}
return null
}
/** Tail recent logs for an app */
async function tailLogs ( appId : string , lines = 50 ) : Promise < string [] | null > {
const res = await requestAction ( 'tail-logs' , { appId , lines : String ( lines ) })
const data = ( res as unknown as Record < string , unknown >). data
if ( res . success && data ) {
return ( data as { lines : string [] }). lines
}
return null
}
2026-03-04 11:22:15 +00:00
/** 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' )
2026-03-06 00:56:39 +00:00
sections . push ( `**Installed apps on this node:** \ n ${ appList } \ nYou can view recent app logs by requesting the tail-logs action with an appId.` )
2026-03-04 11:22:15 +00:00
}
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-06 00:56:39 +00:00
if ( permissions . value . includes ( 'wallet' ) && walletInfo . value . available ) {
2026-03-04 12:15:33 +00:00
const w = walletInfo . value
2026-03-06 00:56:39 +00:00
const parts : string [] = []
if ( w . alias ) parts . push ( w . alias )
if ( w . num_active_channels !== undefined ) parts . push ( ` ${ w . num_active_channels } channels` )
if ( w . num_peers !== undefined ) parts . push ( ` ${ w . num_peers } peers` )
if ( w . balance_sats !== undefined ) parts . push ( `On-chain: ${ w . balance_sats . toLocaleString () } sats` )
if ( w . channel_balance_sats !== undefined ) parts . push ( `In channels: ${ w . channel_balance_sats . toLocaleString () } sats` )
if ( w . synced_to_chain !== undefined ) parts . push ( w . synced_to_chain ? 'synced' : 'syncing' )
sections . push ( `**Lightning (LND):** ${ parts . join ( ' | ' ) } ` )
}
if ( permissions . value . includes ( 'bitcoin' ) && bitcoinInfo . value . available ) {
const btc = bitcoinInfo . value
const syncPct = btc . sync_progress ? ( btc . sync_progress * 100 ). toFixed ( 2 ) + '%' : 'unknown'
const parts = [ `Block ${ btc . block_height ? . toLocaleString () ?? '?' } ` , ` ${ syncPct } synced` ]
if ( btc . chain ) parts . push ( btc . chain )
if ( btc . mempool_tx_count ) parts . push ( `mempool: ${ btc . mempool_tx_count . toLocaleString () } txs` )
sections . push ( `**Bitcoin:** ${ parts . join ( ', ' ) } ` )
2026-03-04 12:15:33 +00:00
}
if ( permissions . value . includes ( 'files' ) && fileList . value . length > 0 ) {
const files = fileList . value
2026-03-07 23:39:41 +00:00
const folders = files . filter ( f => f . type === 'folder' )
const fileItems = files . filter ( f => f . type === 'file' )
const images = fileItems . filter ( f => /\.(jpg|jpeg|png|gif|webp|svg|heic|heif)$/i . test ( f . name ))
const videos = fileItems . filter ( f => /\.(mp4|mkv|avi|mov|webm)$/i . test ( f . name ))
const music = fileItems . filter ( f => /\.(mp3|flac|wav|ogg|m4a|aac|opus)$/i . test ( f . name ))
const docs = fileItems . filter ( f => /\.(pdf|doc|docx|txt|md|ods|xlsx|csv)$/i . test ( f . name ))
const parts : string [] = [ ` ${ files . length } items` ]
if ( folders . length > 0 ) parts . push ( ` ${ folders . length } folders ( ${ folders . map ( f => f . name ). join ( ', ' ) } )` )
if ( images . length > 0 ) parts . push ( ` ${ images . length } images` )
if ( videos . length > 0 ) parts . push ( ` ${ videos . length } videos` )
if ( music . length > 0 ) parts . push ( ` ${ music . length } audio files` )
if ( docs . length > 0 ) parts . push ( ` ${ docs . length } documents` )
const recent = fileItems . slice ( 0 , 15 ). map ( f => f . name ). join ( ', ' )
sections . push ( `**Files:** ${ parts . join ( ' | ' ) } \ nRecent: ${ recent } \ nYou can read file contents by requesting the read-file action with a file path.` )
2026-03-04 12:15:33 +00:00
}
2026-03-04 11:22:15 +00:00
if ( sections . length === 0 ) return ''
2026-03-07 23:39:41 +00:00
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, check service status, browse files, and recommend apps. Available actions: open an app (open-app), install an app (install-app), tail app logs (tail-logs), read a file (read-file), navigate in Archy (navigate). When recommending apps, use [[app_ext:...]] tags and check if they're already installed. When discussing the user's files, mention specific files you can see. If the user asks about their photos, videos, or music, reference the file counts above.`
2026-03-04 11:22:15 +00:00
}
/** 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-06 00:56:39 +00:00
bitcoinInfo : readonly ( bitcoinInfo ),
2026-03-04 11:22:15 +00:00
init ,
destroy ,
refreshContext ,
requestAction ,
2026-03-06 00:56:39 +00:00
readFile ,
tailLogs ,
2026-03-04 11:22:15 +00:00
buildArchyContext ,
}
}