From 1bb72dc87e26b855c0ca76161189d36d381516c9 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 5 Mar 2026 13:50:40 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20AIUI=20node=20capabilities=20?= =?UTF-8?q?=E2=80=94=20file=20reading,=20log=20tailing,=20bitcoin/lnd=20de?= =?UTF-8?q?ep=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/plans/reflective-meandering-castle.md | 196 ++++++++++++------ neode-ui/src/api/filebrowser-client.ts | 34 +++ neode-ui/src/services/contextBroker.ts | 104 ++++++++-- neode-ui/src/types/aiui-protocol.ts | 3 +- 4 files changed, 261 insertions(+), 76 deletions(-) diff --git a/.claude/plans/reflective-meandering-castle.md b/.claude/plans/reflective-meandering-castle.md index 8b30fa57..1278018d 100644 --- a/.claude/plans/reflective-meandering-castle.md +++ b/.claude/plans/reflective-meandering-castle.md @@ -1,75 +1,145 @@ -# Fix Content Clipping + Hide Network Tabs in CloudFolder +# Expand AIUI Node Capabilities ## Context -Content clips/cuts off before reaching the bottom across CloudFolder, Marketplace, AppDetails — both desktop and mobile. Fixed UI elements (toolbars, search) must stay sticky while scrollable content extends to the tab bar (mobile) or viewport bottom (desktop). Also: hide Cloud/Network switcher on mobile in CloudFolder detail view. +AIUI currently sees basic app status and file names but can't read files, check Bitcoin/LND details, or view app logs. Expanding these 4 capabilities makes AIUI a truly useful node assistant. -## Root Cause -`.perspective-container-wrapper` has `height: 100%` + `overflow: hidden` + dynamic `pt-20`/`pt-40`. With `box-sizing: border-box`, the padding shrinks the content area by 80-160px on mobile. +--- +## 1. File Reading (frontend-only) [DONE] + +### `neode-ui/src/api/filebrowser-client.ts` +Add `readFileAsText(path, maxBytes = 102400)` method: +- Fetch from existing `/app/filebrowser/api/raw{path}?auth={token}` endpoint +- Limit response to 100KB (truncate with note) +- Only allow text-like extensions: `.txt`, `.md`, `.json`, `.csv`, `.log`, `.conf`, `.yaml`, `.yml`, `.toml`, `.xml`, `.html`, `.css`, `.js`, `.ts`, `.py`, `.sh` +- Return `{ content: string, truncated: boolean, size: number }` + +### `neode-ui/src/types/aiui-protocol.ts` +Add `'read-file'` and `'tail-logs'` to `AIActionType` union. + +### `neode-ui/src/services/contextBroker.ts` +Add `read-file` action handler: +- Check `files` permission is enabled +- Validate path param exists, validate extension +- Call `fileBrowserClient.readFileAsText(path)` +- Return content in action response + +### `AIUI/packages/app/src/composables/useArchy.ts` +- Add `readFile(path: string)` helper that calls `archyBridge.requestAction('read-file', { path })` +- Update `buildArchyContext()` files section: mention "You can read file contents by requesting the read-file action with a file path." + +--- + +## 2. App Log Viewing (frontend-only) + +### `neode-ui/src/services/contextBroker.ts` +Add `tail-logs` action handler: +- Check `apps` permission is enabled +- Params: `{ appId: string, lines?: string }` (default 50, max 200) +- Call existing `rpcClient.call({ method: 'container-logs', params: { app_id, lines } })` +- Return log lines in action response + +### `AIUI/packages/app/src/composables/useArchy.ts` +- Add `tailLogs(appId: string, lines?: number)` helper +- Update `buildArchyContext()` apps section: "You can view recent app logs by requesting the tail-logs action with an appId." + +--- + +## 3. Bitcoin Deep Data (backend + frontend) + +### `core/archipelago/src/api/rpc/mod.rs` +Add routing: `"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await` + +### New: `core/archipelago/src/api/rpc/bitcoin.rs` +Add `handle_bitcoin_getinfo()`: +- Use `reqwest` to POST to `http://127.0.0.1:8332` with Basic Auth `archipelago:archipelago123` +- Call `getblockchaininfo` JSON-RPC method +- Call `getmempoolinfo` JSON-RPC method +- Return sanitized JSON: +```json +{ + "block_height": 800000, + "sync_progress": 0.9999, + "chain": "main", + "difficulty": 72006146, + "mempool_size": 45000000, + "mempool_tx_count": 12500, + "verification_progress": 0.9999 +} ``` -
← reserves tab bar space (correct) - .perspective-container-wrapper h-100% pt-20 ← padding SHRINKS content area (BUG) - .perspective-container h-100% overflow-hidden - .view-wrapper absolute inset-0 - content div overflow-y-auto h-full ← scroll viewport 80-160px too small +- Handle connection errors gracefully (Bitcoin Core might be syncing or down) + +### `neode-ui/src/services/contextBroker.ts` +Enrich `bitcoin` category sanitizer: +- Call `rpcClient.call({ method: 'bitcoin.getinfo' })` +- Merge with existing container status data +- Return block height, sync %, chain, mempool stats + +### `AIUI/packages/app/src/composables/useArchy.ts` +- Add `bitcoinInfo` ref with block height, sync %, etc. +- Update `buildArchyContext()`: "**Bitcoin:** Block 800,000 (99.99% synced), mainnet, mempool: 12,500 txs" + +--- + +## 4. LND Deep Data (backend + frontend) + +### `core/archipelago/src/api/rpc/mod.rs` +Add routing: `"lnd.getinfo" => self.handle_lnd_getinfo().await` + +### New: `core/archipelago/src/api/rpc/lnd.rs` +Add `handle_lnd_getinfo()`: +- Read admin macaroon from `/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon` +- Use `reqwest` to GET `https://127.0.0.1:8080/v1/getinfo` with `Grpc-Metadata-macaroon` header (hex-encoded) +- GET `https://127.0.0.1:8080/v1/balance/channels` for channel balance +- GET `https://127.0.0.1:8080/v1/balance/blockchain` for on-chain balance +- Accept self-signed cert (`reqwest::Client::builder().danger_accept_invalid_certs(true)`) +- Return sanitized JSON: +```json +{ + "alias": "my-node", + "num_active_channels": 5, + "num_peers": 8, + "synced_to_chain": true, + "block_height": 800000, + "balance_sats": 1500000, + "channel_balance_sats": 3000000, + "pending_open_balance": 0 +} ``` +- **Never expose**: private keys, seed, macaroon, node pubkey (optional — could include for identification) +- Handle errors: LND might be locked, syncing, or not installed -## Solution: Move padding from wrapper to scroll container +### `neode-ui/src/services/contextBroker.ts` +Enrich `wallet` category: +- Call `rpcClient.call({ method: 'lnd.getinfo' })` +- Return alias, channels, balances, sync status -### File 1: `neode-ui/src/views/Dashboard.vue` [DONE] +### `AIUI/packages/app/src/composables/useArchy.ts` +- Add `lndInfo` ref +- Update `buildArchyContext()`: "**Lightning:** 5 channels, 3M sats in channels, 1.5M on-chain, synced" -**A. Add computed** (~line 440): -```ts -const mobileTabPaddingTop = computed(() => { - if (typeof window === 'undefined' || window.innerWidth >= 768) return 0 - if (showAppsTabs.value && showNetworkTabs.value) return 160 - if (showAppsTabs.value || showNetworkTabs.value) return 80 - return 0 -}) -``` +--- -**B. Remove padding from wrapper** (line 258): -Remove `'pt-40': showAppsTabs && showNetworkTabs, 'pt-20': showAppsTabs !== showNetworkTabs` from `:class`. +## File Summary -**C. Apply offset to content div instead** (lines 269-277): -```html -
-``` -- When mobile tabs showing: `:style` overrides `pt-4` with dynamic value -- When no tabs (or desktop): `:style` is undefined, Tailwind `pt-4 md:pt-8` applies -- Bottom padding reduced from `pb-28 md:pb-24` to `pb-4 md:pb-8` (main's `pb-20` already handles tab bar) +| File | Change | +|------|--------| +| `neode-ui/src/api/filebrowser-client.ts` | Add `readFileAsText()` | +| `neode-ui/src/types/aiui-protocol.ts` | Add `read-file`, `tail-logs` action types | +| `neode-ui/src/services/contextBroker.ts` | Add 2 action handlers + enrich bitcoin/wallet categories | +| `neode-ui/src/stores/aiPermissions.ts` | Update category descriptions | +| `core/archipelago/src/api/rpc/mod.rs` | Add 2 route entries | +| `core/archipelago/src/api/rpc/bitcoin.rs` | New: Bitcoin Core RPC proxy | +| `core/archipelago/src/api/rpc/lnd.rs` | New: LND REST proxy | +| `AIUI/packages/app/src/composables/useArchy.ts` | Add helpers + enrich buildArchyContext() | -**D. Hide Cloud/Network tabs in CloudFolder** (line 451): -```ts -if (route.name === 'cloud-folder') return false -``` - -### File 2: `neode-ui/src/views/CloudFolder.vue` [DONE] -Delete spacer div at line 156. - -### File 3: `neode-ui/src/views/AppDetails.vue` [DONE] -Delete spacer div at line 377. - -### File 4: `neode-ui/src/views/MarketplaceAppDetails.vue` [DONE] -Delete spacer div at line 324. - -### File 5: `neode-ui/src/views/Marketplace.vue` [DONE] -Change `pb-48` → `pb-4` on line 121. - -## Safety -- 3D transitions preserved: `overflow: hidden` stays on wrapper + perspective-container -- Chat view unaffected: has its own branch bypassing the content div -- Desktop unaffected: `mobileTabPaddingTop` returns 0 -- Back button space preserved: `needsMobileBackButtonSpace` still adds extra bottom padding - -## Verification [DONE] -1. `cd neode-ui && npm run build` -2. `./scripts/deploy-to-target.sh --live` -3. Test all views on mobile + desktop: CloudFolder, Marketplace, AppDetails, Chat, Home, page transitions +## Verification +1. `cd neode-ui && npm run build` — frontend builds +2. `./scripts/deploy-to-target.sh --live` — deploys + builds Rust backend on server +3. Test in AIUI chat: + - "What files do I have?" → sees file list + - "Read my config.txt" → gets file content + - "How's my Bitcoin node?" → block height, sync %, mempool + - "What's my Lightning balance?" → channel count, sats balance + - "Why is Mempool not working?" → views recent logs + - "Show me the last 50 lines of Bitcoin logs" → log output diff --git a/neode-ui/src/api/filebrowser-client.ts b/neode-ui/src/api/filebrowser-client.ts index 850f8e72..c35f7f56 100644 --- a/neode-ui/src/api/filebrowser-client.ts +++ b/neode-ui/src/api/filebrowser-client.ts @@ -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 { const safePath = oldPath.startsWith('/') ? oldPath : `/${oldPath}` const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1) diff --git a/neode-ui/src/services/contextBroker.ts b/neode-ui/src/services/contextBroker.ts index 466bc957..59fd6eed 100644 --- a/neode-ui/src/services/contextBroker.ts +++ b/neode-ui/src/services/contextBroker.ts @@ -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): unknown { + // T7: Bitcoin status + deep data from backend RPC + private async sanitizeBitcoin(store: ReturnType): Promise { 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): unknown { + // T12: Wallet — LND deep data from backend RPC + private async sanitizeWallet(store: ReturnType): Promise { 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({ 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) diff --git a/neode-ui/src/types/aiui-protocol.ts b/neode-ui/src/types/aiui-protocol.ts index 11d0df22..a1aa2361 100644 --- a/neode-ui/src/types/aiui-protocol.ts +++ b/neode-ui/src/types/aiui-protocol.ts @@ -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 {