feat: add AIUI node capabilities — file reading, log tailing, bitcoin/lnd deep data

Add readFileAsText() to filebrowser client, read-file and tail-logs action
handlers to context broker, bitcoin.getinfo and lnd.getinfo RPC enrichment
for context categories, and update AIUI protocol types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-05 13:50:40 +00:00
parent 11cee9dc70
commit 1bb72dc87e
4 changed files with 261 additions and 76 deletions

View File

@@ -121,6 +121,40 @@ class FileBrowserClient {
return { totalSize, folderCount, fileCount }
}
private static TEXT_EXTENSIONS = new Set([
'txt', 'md', 'json', 'csv', 'log', 'conf', 'yaml', 'yml', 'toml', 'xml',
'html', 'css', 'js', 'ts', 'py', 'sh', 'bash', 'env', 'ini', 'cfg',
'sql', 'rs', 'go', 'java', 'c', 'h', 'cpp', 'hpp', 'rb', 'php',
'dockerfile', 'makefile', 'gitignore', 'editorconfig',
])
isTextFile(path: string): boolean {
const ext = path.includes('.') ? path.split('.').pop()!.toLowerCase() : ''
const name = path.split('/').pop()?.toLowerCase() || ''
return FileBrowserClient.TEXT_EXTENSIONS.has(ext) || FileBrowserClient.TEXT_EXTENSIONS.has(name)
}
async readFileAsText(path: string, maxBytes = 102400): Promise<{ content: string; truncated: boolean; size: number }> {
if (!this.isAuthenticated) {
const ok = await this.login()
if (!ok) throw new Error('FileBrowser authentication failed')
}
if (!this.isTextFile(path)) {
throw new Error(`Cannot read binary file: ${path}`)
}
const safePath = path.startsWith('/') ? path : `/${path}`
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
headers: this.headers(),
})
if (!res.ok) throw new Error(`Failed to read file: ${res.status}`)
const blob = await res.blob()
const size = blob.size
const truncated = size > maxBytes
const slice = truncated ? blob.slice(0, maxBytes) : blob
const content = await slice.text()
return { content, truncated, size }
}
async rename(oldPath: string, newName: string): Promise<void> {
const safePath = oldPath.startsWith('/') ? oldPath : `/${oldPath}`
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)

View File

@@ -177,6 +177,14 @@ export class ContextBroker {
error = 'Missing query parameter'
break
case 'read-file':
this.handleReadFileAction(id, params.path)
return
case 'tail-logs':
this.handleTailLogsAction(id, params.appId, params.lines)
return
default:
error = `Unknown action: ${action}`
}
@@ -334,8 +342,8 @@ export class ContextBroker {
}
}
// T7: Bitcoin status from bundled app
private sanitizeBitcoin(store: ReturnType<typeof useAppStore>): unknown {
// T7: Bitcoin status + deep data from backend RPC
private async sanitizeBitcoin(store: ReturnType<typeof useAppStore>): Promise<unknown> {
const packages = store.packages || {}
const containerStore = useContainerStore()
@@ -351,10 +359,19 @@ export class ContextBroker {
return { available: false, message: 'Bitcoin Core not running' }
}
return {
available: true,
status: 'running',
network: 'mainnet',
try {
const info = await rpcClient.call<{
block_height: number
sync_progress: number
chain: string
difficulty: number
mempool_size: number
mempool_tx_count: number
verification_progress: number
}>({ method: 'bitcoin.getinfo' })
return { available: true, status: 'running', ...info }
} catch {
return { available: true, status: 'running', network: 'mainnet' }
}
}
@@ -437,8 +454,8 @@ export class ContextBroker {
}
}
// T12: Wallet — LND aggregate data
private sanitizeWallet(store: ReturnType<typeof useAppStore>): unknown {
// T12: Wallet — LND deep data from backend RPC
private async sanitizeWallet(store: ReturnType<typeof useAppStore>): Promise<unknown> {
const packages = store.packages || {}
const containerStore = useContainerStore()
@@ -454,10 +471,20 @@ export class ContextBroker {
return { available: false, message: 'Lightning (LND) not running' }
}
return {
available: true,
status: 'running',
message: 'LND is running. Balance details require backend wallet RPC.',
try {
const info = await rpcClient.call<{
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
}>({ method: 'lnd.getinfo' })
return { available: true, status: 'running', ...info }
} catch {
return { available: true, status: 'running', message: 'LND is running but detailed info unavailable' }
}
}
@@ -470,6 +497,59 @@ export class ContextBroker {
}
}
private async handleReadFileAction(id: string, path?: string) {
const perms = useAIPermissionsStore()
if (!perms.isEnabled('files')) {
this.postToIframe({ type: 'action:response', id, success: false, error: 'File access not permitted' } satisfies ArchyActionResponse)
return
}
if (!path) {
this.postToIframe({ type: 'action:response', id, success: false, error: 'Missing path parameter' } satisfies ArchyActionResponse)
return
}
try {
if (!fileBrowserClient.isAuthenticated) {
const ok = await fileBrowserClient.login()
if (!ok) throw new Error('FileBrowser authentication failed')
}
const result = await fileBrowserClient.readFileAsText(path)
this.postToIframe({
type: 'action:response', id, success: true,
data: { content: result.content, truncated: result.truncated, size: result.size, path },
} as ArchyActionResponse)
} catch (err) {
this.postToIframe({
type: 'action:response', id, success: false,
error: err instanceof Error ? err.message : 'Failed to read file',
} satisfies ArchyActionResponse)
}
}
private async handleTailLogsAction(id: string, appId?: string, linesStr?: string) {
const perms = useAIPermissionsStore()
if (!perms.isEnabled('apps')) {
this.postToIframe({ type: 'action:response', id, success: false, error: 'App access not permitted' } satisfies ArchyActionResponse)
return
}
if (!appId) {
this.postToIframe({ type: 'action:response', id, success: false, error: 'Missing appId parameter' } satisfies ArchyActionResponse)
return
}
const lines = Math.min(parseInt(linesStr || '50', 10) || 50, 200)
try {
const logs = await rpcClient.call<string[]>({ method: 'container-logs', params: { app_id: appId, lines } })
this.postToIframe({
type: 'action:response', id, success: true,
data: { appId, lines: logs, count: logs.length },
} as ArchyActionResponse)
} catch (err) {
this.postToIframe({
type: 'action:response', id, success: false,
error: err instanceof Error ? err.message : 'Failed to fetch logs',
} satisfies ArchyActionResponse)
}
}
private postToIframe(msg: ArchyResponse) {
if (!this.iframe.value?.contentWindow) return
this.iframe.value.contentWindow.postMessage(msg, this.allowedOrigin)

View File

@@ -19,7 +19,7 @@ export type AIContextCategory =
| 'bitcoin'
/** Actions AIUI can request Archy to perform */
export type AIActionType = 'install-app' | 'open-app' | 'navigate' | 'launch-app' | 'search-web'
export type AIActionType = 'install-app' | 'open-app' | 'navigate' | 'launch-app' | 'search-web' | 'read-file' | 'tail-logs'
// ─── AIUI → Archy (Requests) ───────────────────────────────────────────────
@@ -65,6 +65,7 @@ export interface ArchyActionResponse {
id: string
success: boolean
error?: string
data?: unknown
}
export interface ArchyThemeResponse {