Four issues found via live testing of the embedded Chat/AIUI panel (Archipelago phase 02-07 follow-up), each root-caused rather than patched over: 1. Loading overlay never dismissed: archyBridge.init() used window.location.origin (this iframe's OWN origin) as the target for postMessage calls TO the parent, instead of the parent's actual origin. Silently correct only when AIUI is served same-origin as its host (production's /aiui/ proxy) — broken the moment AIUI runs on a different origin than its embedding page (any dev setup with a separate AIUI dev server). The 'ready' message, and every permissions/theme/context/action response after it, was being dropped by the browser. Fixed by deriving the parent's real origin from document.referrer (archyBridge.ts). 2. White/black background instead of the branded look: initTheme() decides light/dark from localStorage or the OS's prefers-color- scheme, with no awareness of being embedded — App.vue now forces dark immediately on mount when embedded (before any handshake completes) and useArchy.ts's theme-update callback now applies Archy's reported mode too. Separately, body had no background-color at all, so ChatPage.vue's embedded `background: transparent` fell through to the browser's white UA default; main.css now paints body to match the active theme. And ChatPage.vue's embedded branch was opting out of the same background-image treatment the standalone dark app uses — it now shares that exact styling instead of a flat fallback color, matching the standalone look precisely. 3. Dead end when no AI provider credential is available: useAI.ts now emits a narrow, one-shot needsApiKey signal (401/403, "api key", "unauthorized", or a proxy-unreachable failure — deliberately not every transient error) that ChatWindow.vue watches to auto-open Settings, so the user lands on the fix instead of a silent/dead chat. 4. claude-proxy.ts's CLI fallback spawned a hardcoded ~/.local/bin/claude path, breaking with ENOENT on any machine where the CLI lives elsewhere (e.g. an nvm install). Now resolves via `command -v claude` first (an optional CLAUDE_BIN env override, then the historical path, then the bare command name as a last resort so spawn() itself can still try PATH), and surfaces an actionable in-UI error naming three ways to fix it when none resolve. Verified: full send→spawn→response round trip against the local proxy (both directly and through vite's /api/claude proxy), vue-tsc clean, vitest 332/335 passing (3 pre-existing unrelated failures, confirmed present before this commit too), production build clean with both chatExpanded/mobileChat flags and the new background rule present in the built assets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
import { ref, readonly } from 'vue'
|
|
import { archyBridge } from '@/services/archyBridge'
|
|
import { useTheme } from '@/composables/useTheme'
|
|
import {
|
|
mockArchyApps, mockArchySystem, mockArchyNetwork,
|
|
mockArchyWallet, mockArchyBitcoin, mockArchyFiles,
|
|
} from '@/mocks/archy'
|
|
|
|
type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files' | 'bitcoin'
|
|
|
|
interface ArchyApp {
|
|
id: string
|
|
name: string
|
|
state: string
|
|
status: string
|
|
}
|
|
|
|
interface ArchySystemInfo {
|
|
version?: string
|
|
name?: string
|
|
}
|
|
|
|
interface ArchyNetworkInfo {
|
|
connected?: boolean
|
|
}
|
|
|
|
export interface ArchyWalletInfo {
|
|
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
|
|
}
|
|
|
|
export interface ArchyFileEntry {
|
|
name: string
|
|
path: string
|
|
size?: number
|
|
modified?: string
|
|
type: 'file' | 'folder'
|
|
}
|
|
|
|
export interface ArchyBitcoinInfo {
|
|
available: boolean
|
|
block_height?: number
|
|
sync_progress?: number
|
|
chain?: string
|
|
mempool_tx_count?: number
|
|
mempool_size?: number
|
|
}
|
|
|
|
// 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>({})
|
|
const walletInfo = ref<ArchyWalletInfo>({})
|
|
const fileList = ref<ArchyFileEntry[]>([])
|
|
const bitcoinInfo = ref<ArchyBitcoinInfo>({ available: false })
|
|
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
|
|
|
|
// 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
|
|
}
|
|
|
|
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 — 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.
|
|
const unsubTheme = archyBridge.onThemeUpdate((theme) => {
|
|
accentColor.value = theme.accent
|
|
applyAccentColor(theme.accent)
|
|
useTheme().setTheme(theme.mode)
|
|
})
|
|
cleanups.push(unsubTheme)
|
|
|
|
// Request theme on init
|
|
archyBridge.requestTheme()
|
|
}
|
|
|
|
/** Fetch context for all permitted categories */
|
|
async function fetchPermittedContext(cats: AIContextCategory[]) {
|
|
const fetches: Promise<void>[] = []
|
|
|
|
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)
|
|
})
|
|
}
|
|
|
|
if (cats.includes('apps')) {
|
|
fetches.push(fetchCategory('apps', (data) => { installedApps.value = data as ArchyApp[] }, Array.isArray))
|
|
}
|
|
|
|
if (cats.includes('system')) {
|
|
fetches.push(fetchCategory('system', (data) => { systemInfo.value = data as ArchySystemInfo }))
|
|
}
|
|
|
|
if (cats.includes('network')) {
|
|
fetches.push(fetchCategory('network', (data) => { networkInfo.value = data as ArchyNetworkInfo }))
|
|
}
|
|
|
|
if (cats.includes('wallet')) {
|
|
fetches.push(fetchCategory('wallet', (data) => { walletInfo.value = data as ArchyWalletInfo }))
|
|
}
|
|
|
|
if (cats.includes('bitcoin')) {
|
|
fetches.push(fetchCategory('bitcoin', (data) => { bitcoinInfo.value = data as ArchyBitcoinInfo }))
|
|
}
|
|
|
|
if (cats.includes('files')) {
|
|
fetches.push(fetchCategory('files', (data) => { fileList.value = data as ArchyFileEntry[] }, Array.isArray))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/** 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
|
|
}
|
|
|
|
/** 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}\nYou can view recent app logs by requesting the tail-logs action with an appId.`)
|
|
}
|
|
|
|
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'}`)
|
|
}
|
|
|
|
if (permissions.value.includes('wallet') && walletInfo.value.available) {
|
|
const w = walletInfo.value
|
|
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(', ')}`)
|
|
}
|
|
|
|
if (permissions.value.includes('files') && fileList.value.length > 0) {
|
|
const files = fileList.value
|
|
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.`)
|
|
}
|
|
|
|
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, 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.`
|
|
}
|
|
|
|
/** 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),
|
|
walletInfo: readonly(walletInfo),
|
|
fileList: readonly(fileList),
|
|
bitcoinInfo: readonly(bitcoinInfo),
|
|
init,
|
|
destroy,
|
|
refreshContext,
|
|
requestAction,
|
|
readFile,
|
|
tailLogs,
|
|
buildArchyContext,
|
|
}
|
|
}
|