diff --git a/.claude/skills/pwa-icon-cache-fix/SKILL.md b/.claude/skills/pwa-icon-cache-fix/SKILL.md new file mode 100644 index 00000000..dbc65f81 --- /dev/null +++ b/.claude/skills/pwa-icon-cache-fix/SKILL.md @@ -0,0 +1,102 @@ +--- +name: pwa-icon-cache-fix +description: Use when the user reports a PWA icon not updating, stale PWA icon, wrong icon after install, or any PWA caching issue. Also applies when changing PWA icons in a Vite + vite-plugin-pwa project. +version: 2.0.0 +--- + +# PWA Icon Cache Fix + +## Problem + +PWA icons are cached at FOUR independent layers: +1. **Service worker cache** (Workbox precache) +2. **Browser HTTP cache** +3. **Browser manifest resources** (Chromium stores resized icons in its profile data, keyed by a permanent extension ID tied to the origin — NEVER re-fetched even after uninstall/reinstall) +4. **macOS .app bundle** (`.icns` file baked into the `.app` in `~/Applications/`) + +Query string cache busting (`?v=2`) and uninstall/reinstall do NOT fix this. Chromium reuses the same extension ID for the same origin, so it keeps the old cached icons. + +## Fix Steps + +### 1. Verify icon files on disk and server are correct + +```bash +# Visual check +Read packages/app/public/pwa-192x192.png +Read packages/app/public/pwa-512x512.png + +# Hash match check +curl -s http://localhost:5173/pwa-192x192.png | md5 +md5 -q packages/app/public/pwa-192x192.png +``` + +### 2. Find the PWA's Chromium extension ID + +Read the installed `.app` bundle's `Info.plist` to get the `CrAppModeShortcutID`: + +```bash +plutil -p "~/Applications/Brave Browser Apps.localized/AIUI.app/Contents/Info.plist" | grep CrAppModeShortcutID +``` + +This returns an ID like `idemibpphagihbobmgmaojhjfidlfpdl`. + +### 3. Overwrite the cached icons in browser profile + +Chromium stores resized icons at: +`~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/Manifest Resources/{ID}/Icons/` + +Overwrite every size using `sips`: + +```bash +ICON_DIR="~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/Manifest Resources/{ID}/Icons" +SRC="packages/app/public/pwa-512x512.png" +for size in 32 48 64 96 128 192 256 512; do + sips -z $size $size "$SRC" --out "${ICON_DIR}/${size}.png" +done +``` + +### 4. Rebuild the macOS .icns in the .app bundle + +```bash +ICONSET="/tmp/aiui.iconset" +mkdir -p "$ICONSET" +SRC="packages/app/public/pwa-512x512.png" +sips -z 16 16 "$SRC" --out "$ICONSET/icon_16x16.png" +sips -z 32 32 "$SRC" --out "$ICONSET/icon_16x16@2x.png" +sips -z 32 32 "$SRC" --out "$ICONSET/icon_32x32.png" +sips -z 64 64 "$SRC" --out "$ICONSET/icon_32x32@2x.png" +sips -z 128 128 "$SRC" --out "$ICONSET/icon_128x128.png" +sips -z 256 256 "$SRC" --out "$ICONSET/icon_128x128@2x.png" +sips -z 256 256 "$SRC" --out "$ICONSET/icon_256x256.png" +sips -z 512 512 "$SRC" --out "$ICONSET/icon_256x256@2x.png" +sips -z 512 512 "$SRC" --out "$ICONSET/icon_512x512.png" +cp "$SRC" "$ICONSET/icon_512x512@2x.png" +iconutil -c icns "$ICONSET" -o "~/Applications/Brave Browser Apps.localized/AIUI.app/Contents/Resources/app.icns" +``` + +### 5. Flush macOS icon cache + +```bash +touch "~/Applications/Brave Browser Apps.localized/AIUI.app" +killall Finder +killall Dock +``` + +### 6. Bump PWA_CACHE_VERSION in main.ts + +Increment the `PWA_CACHE_VERSION` constant — this nukes all SW caches on next page load for web-layer caching. + +### 7. Delete stale build artifacts + +Remove old `dist/` and `dev-dist/` SW/manifest files. + +## Browser-Specific Paths + +- **Brave**: `~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Web Applications/` +- **Chrome**: `~/Library/Application Support/Google/Chrome/Default/Web Applications/` +- **PWA apps (Brave)**: `~/Applications/Brave Browser Apps.localized/` +- **PWA apps (Chrome)**: `~/Applications/Chrome Apps.localized/` + +## Key Insight + +Chromium assigns a permanent extension ID per origin (e.g., `localhost:5173`). This ID persists across uninstall/reinstall. The icon cache in `Manifest Resources/{ID}/Icons/` is populated ONCE and never refreshed from the manifest. The only fix is to overwrite the files directly on disk. diff --git a/packages/app/src/App.vue b/packages/app/src/App.vue index 154ec0f4..639151f7 100644 --- a/packages/app/src/App.vue +++ b/packages/app/src/App.vue @@ -2,6 +2,7 @@
+ windowWidth.value < 1024) const { viewportHeight, isKeyboardOpen } = useVisualViewport() function onResize() { windowWidth.value = window.innerWidth } -// On mobile, bind height to visualViewport.height so the container -// actually shrinks when the keyboard opens (dvh doesn't do this reliably) +// On mobile, always bind height to visualViewport so the container +// respects the actual visible area (dvh doesn't reliably exclude +// Safari's bottom toolbar when body is position:fixed) const rootStyle = computed(() => { - if (isMobile.value && isKeyboardOpen.value) { + if (isMobile.value && viewportHeight.value > 0) { return { height: `${viewportHeight.value}px`, overflow: 'hidden' } } return {} diff --git a/packages/app/src/__tests__/fixtures/guideConversation.ts b/packages/app/src/__tests__/fixtures/guideConversation.ts new file mode 100644 index 00000000..330f8bbc --- /dev/null +++ b/packages/app/src/__tests__/fixtures/guideConversation.ts @@ -0,0 +1,136 @@ +/** + * AIUI Guide — pre-loaded as a chat conversation so users can + * read it right in the chat window. + */ + +export function guideToConversation(): { + id: string + title: string + messages: { id: string; role: string; content: string; timestamp: number }[] + createdAt: number + updatedAt: number +} { + const baseTime = 1772496000000 + + const guideContent = `# AIUI Guide — Your Node Assistant + +AIUI is your AI assistant running directly on your Archipelago node. It can see your installed apps, read your files, check Bitcoin and Lightning status, and help you manage everything — all privately, with no data leaving your node. + +--- + +## Node Awareness + +AIUI automatically knows about your node setup. Just ask naturally: + +- *"What apps do I have installed?"* +- *"Is my node connected to the network?"* +- *"What version of Archipelago am I running?"* + +--- + +## File Browsing & Reading + +AIUI can browse and read text files stored in your Nextcloud. Supported formats: \`.txt\`, \`.md\`, \`.json\`, \`.csv\`, \`.log\`, \`.yaml\`, \`.conf\`, \`.toml\`, \`.xml\`, \`.html\`, \`.css\`, \`.js\`, \`.ts\`, \`.py\`, \`.sh\`, and more. + +- *"What files do I have?"* +- *"Read my config.yaml file"* +- *"Show me the contents of notes.md"* +- *"Summarize my todo.txt"* + +> Files are read up to 100KB. Larger files are truncated. Binary files (images, videos) cannot be read as text. + +--- + +## Bitcoin Node Status + +If you have Bitcoin Core running, AIUI can check sync status, block height, and mempool info in real-time. + +- *"How's my Bitcoin node doing?"* +- *"What block height am I on?"* +- *"Is my node fully synced?"* +- *"How many transactions are in the mempool?"* + +--- + +## Lightning Network (LND) + +AIUI can query your LND node for channels, peers, balances, and sync status. Private keys and macaroons are never exposed. + +- *"What's my Lightning balance?"* +- *"How many channels do I have open?"* +- *"How many peers is my node connected to?"* +- *"Is my Lightning node synced?"* + +--- + +## App Logs + +When an app isn't working right, AIUI can pull recent log output to help diagnose issues. + +- *"Why is Mempool not working?"* +- *"Show me the Bitcoin Core logs"* +- *"What errors is Nextcloud showing?"* +- *"Show me the last 100 lines of LND logs"* + +--- + +## App Management + +AIUI can help you navigate your node, open apps, and install new ones. + +- *"Open Mempool"* +- *"Install BTCPay Server"* +- *"Take me to the Settings page"* +- *"What apps are available to install?"* + +--- + +## Chat Features + +- **Conversation History** — All chats saved locally. Use the history panel to switch between them. +- **Edit Messages** — Click any sent message to edit and re-send. +- **Branch Conversations** — Fork at any point to explore a different direction. +- **Web Search** — When enabled, AIUI searches the web for current info. +- **Image Support** — Attach images for visual questions. + +--- + +## Privacy & Permissions + +AIUI only accesses what you allow. Node data categories (apps, files, wallet, bitcoin, network, system) are permission-gated through the Archy permissions panel. All processing goes through your node's Claude proxy — your conversations and data never touch third-party servers beyond the AI API. Private keys, seeds, and macaroons are never exposed. + +--- + +## Tips + +- Be specific — *"Read my bitcoin.conf"* works better than *"show me config files"* +- AIUI remembers context within a conversation, so ask follow-ups +- If something seems wrong with an app, ask AIUI to check the logs first +- You can ask AIUI to explain what a config file does after reading it +- Use the history panel to return to previous conversations at any time + +--- + +Try asking me something! For example: *"What apps do I have installed?"* or *"How's my Bitcoin node?"*` + + return { + id: 'aiui-guide', + title: 'AIUI Guide', + messages: [ + { + id: 'guide-q', + role: 'user', + content: 'How do I use AIUI?', + timestamp: baseTime, + }, + { + id: 'guide-a', + role: 'assistant', + content: guideContent, + timestamp: baseTime + 2000, + }, + ], + createdAt: baseTime, + updatedAt: baseTime + 2000, + } +} diff --git a/packages/app/src/__tests__/fixtures/nodeDemoPrompts.ts b/packages/app/src/__tests__/fixtures/nodeDemoPrompts.ts new file mode 100644 index 00000000..7b3454b6 --- /dev/null +++ b/packages/app/src/__tests__/fixtures/nodeDemoPrompts.ts @@ -0,0 +1,269 @@ +/** + * Node Demo Prompts — pre-populated conversations demonstrating + * AIUI's local node search and management capabilities. + */ + +export interface NodeDemoPrompt { + id: string + userQuery: string + assistantResponse: string +} + +export const nodeDemoPrompts: NodeDemoPrompt[] = [ + { + id: 'node-apps', + userQuery: 'What apps do I have installed on my node?', + assistantResponse: `You have the following apps installed on your Archipelago node: + +- **Bitcoin Core** (running) — Your Bitcoin full node, fully synced to the blockchain +- **LND** (running) — Lightning Network daemon for fast, cheap payments +- **Mempool** (running) — Visual Bitcoin blockchain explorer and mempool monitor +- **Nextcloud** (running) — Your private cloud storage for files, photos, and documents +- **BTCPay Server** (running) — Self-hosted payment processor for accepting Bitcoin +- **Tor** (running) — Anonymous network routing for privacy + +All 6 apps are running normally. Would you like me to check the logs for any of them, or open one up?`, + }, + { + id: 'node-bitcoin', + userQuery: "How's my Bitcoin node doing?", + assistantResponse: `Your Bitcoin Core node is looking healthy: + +**Bitcoin Node Status** +- **Block Height:** 886,421 +- **Sync Progress:** 100.00% (fully synced) +- **Chain:** mainnet +- **Mempool:** 42,318 transactions (68.2 MB) + +Your node is fully caught up with the Bitcoin network. The mempool is moderately full — transaction fees are around 12-25 sat/vB for next-block confirmation. This is a typical level of activity. + +Would you like me to check your Lightning node status as well?`, + }, + { + id: 'node-lightning', + userQuery: "Yes, what's my Lightning balance?", + assistantResponse: `Here's your Lightning Network status: + +**Lightning (LND)** +- **Node Alias:** ArchyNode-228 +- **Channels:** 5 active channels +- **Peers:** 8 connected peers +- **Channel Balance:** 3,150,000 sats (in channels) +- **On-chain Balance:** 1,245,000 sats +- **Pending Open:** 0 sats +- **Sync:** Fully synced to chain + +Your total Lightning capacity is about 3.15M sats across 5 channels, plus 1.24M sats on-chain. Everything looks healthy — all channels are active and your node is fully synced. + +Your total Bitcoin holdings visible to the node: **4,395,000 sats** (~$4,395 at current rates).`, + }, + { + id: 'node-files', + userQuery: 'What files do I have stored on my node?', + assistantResponse: `You have **47 files** in your Nextcloud storage. Here are the most recent: + +- bitcoin.conf +- lnd.conf +- notes.md +- budget-2026.csv +- family-photos/ (folder) +- Documents/ (folder) +- backup-keys.txt +- todo.txt +- project-ideas.md +- recipes/ (folder) + +Would you like me to read any of these files? I can open text files like .txt, .md, .json, .csv, .conf, .yaml, and more.`, + }, + { + id: 'node-read-file', + userQuery: 'Read my todo.txt', + assistantResponse: `Here's the contents of **todo.txt** (342 bytes): + +\`\`\` +Shopping List +- Groceries for the week +- New HDMI cable for the node monitor +- Backup hard drive (4TB minimum) + +Node Tasks +- Open more Lightning channels +- Set up Ride The Lightning (RTL) +- Configure automatic channel backups +- Test BTCPay Server checkout flow + +Personal +- Call dentist for appointment +- Renew domain registration +- Plan weekend hike +\`\`\` + +Looks like you've got some node tasks on your list! I can help you with a few of those — I can open the app marketplace to install Ride The Lightning, or check your current channel backup configuration. Want me to help with any of these?`, + }, + { + id: 'node-read-config', + userQuery: 'Show me my bitcoin.conf', + assistantResponse: `Here's your **bitcoin.conf** (512 bytes): + +\`\`\`conf +# Bitcoin Core Configuration +server=1 +daemon=1 +txindex=1 + +# RPC Settings +rpcuser=archipelago +rpcpassword=archipelago123 +rpcallowip=127.0.0.1 +rpcbind=127.0.0.1 +rpcport=8332 + +# Network +listen=1 +maxconnections=40 +maxuploadtarget=5000 + +# Mempool +maxmempool=300 +mempoolexpiry=72 + +# Performance +dbcache=450 +par=2 + +# ZMQ (for LND) +zmqpubrawblock=tcp://127.0.0.1:28332 +zmqpubrawtx=tcp://127.0.0.1:28333 +\`\`\` + +Your config looks solid. A few observations: +- **txindex=1** is enabled, which is needed for Electrum Server and block explorers +- **dbcache=450** MB — reasonable for your hardware +- **ZMQ** is configured for LND to receive real-time block/tx notifications +- **maxuploadtarget=5000** MB/day limits upload bandwidth + +Everything is properly set up for running LND and Mempool alongside Bitcoin Core.`, + }, + { + id: 'node-app-logs', + userQuery: "Mempool seems slow, can you check what's going on?", + assistantResponse: `Let me check the Mempool logs for you. + +Here are the last 50 lines of **Mempool** logs: + +\`\`\` +[2026-03-05 14:23:01] Updating mempool stats... +[2026-03-05 14:23:02] Fetched 42318 transactions from Bitcoin Core +[2026-03-05 14:23:03] Block 886421 indexed successfully +[2026-03-05 14:23:05] WebSocket: 12 clients connected +[2026-03-05 14:23:10] Mining pool stats updated +[2026-03-05 14:23:15] Mempool size: 68.2 MB (42318 txs) +[2026-03-05 14:23:20] Fee estimation updated: 12-25 sat/vB +[2026-03-05 14:23:25] WARN: Electrs sync delay detected (2.3s) +[2026-03-05 14:23:30] Updating difficulty adjustment... +[2026-03-05 14:23:35] Hashrate calculation complete +\`\`\` + +I see one warning: **Electrs sync delay of 2.3 seconds**. This is the Electrum Server indexer that Mempool uses for address lookups. A small delay like this is usually temporary and happens when: + +1. A new block was just found (extra indexing work) +2. The mempool is particularly full (more data to process) + +The rest of the logs look normal. Mempool is serving 12 active WebSocket connections. If the slowness persists, you might want to restart the Mempool container. Want me to do that?`, + }, + { + id: 'node-open-app', + userQuery: 'Open Mempool so I can check it myself', + assistantResponse: `Opening **Mempool** for you now. The app should appear in a new view — you'll see the real-time mempool visualization, fee estimates, and recent blocks. + +If it's still feeling slow, try refreshing the page. The Electrs sync delay I noticed earlier should resolve itself within a few minutes.`, + }, + { + id: 'node-install', + userQuery: 'What other apps can I install?', + assistantResponse: `Here are some apps available in the Archipelago marketplace that you haven't installed yet: + +**Recommended for your setup:** +- **Ride The Lightning (RTL)** — Visual Lightning node manager with channel management, payments, and routing fee settings. Great companion to LND. +- **ThunderHub** — Another Lightning dashboard with a modern UI. More advanced routing and rebalancing tools. +- **BOS (Balance of Satoshis)** — CLI tool for advanced Lightning channel management and rebalancing. + +**Privacy & Communication:** +- **Nostr Relay** — Run your own Nostr relay for censorship-resistant social media +- **SimpleX Chat Server** — Private messaging server + +**Other:** +- **Home Assistant** — Smart home automation +- **Gitea** — Self-hosted Git repository (like a private GitHub) +- **Vaultwarden** — Self-hosted password manager (Bitwarden compatible) + +Since you mentioned wanting to set up RTL in your todo list, would you like me to install **Ride The Lightning** for you?`, + }, + { + id: 'node-summary', + userQuery: 'Give me a full status summary of my node', + assistantResponse: `Here's your complete **Archipelago Node Status**: + +--- + +**System** +- Archipelago v0.1.0 +- Network: Connected +- Uptime: 14 days, 6 hours + +**Bitcoin Core** — Running +- Block: 886,421 (100% synced) +- Mempool: 42,318 txs (68.2 MB) +- Connections: 38 peers + +**Lightning (LND)** — Running +- Alias: ArchyNode-228 +- Channels: 5 active, 0 pending +- Peers: 8 connected +- Channel balance: 3,150,000 sats +- On-chain balance: 1,245,000 sats +- Total: 4,395,000 sats + +**Apps** — 6/6 running +- Bitcoin Core, LND, Mempool, Nextcloud, BTCPay Server, Tor + +**Storage** +- Nextcloud: 47 files +- Blockchain: ~600 GB + +--- + +Everything is running smoothly. No errors or warnings in the last 24 hours. Your node is fully synced, all channels are active, and all apps are healthy.`, + }, +] + +/** + * Build a single conversation with all node demo prompts as sequential messages. + */ +export function nodeDemoToConversation(): { + id: string + title: string + messages: { id: string; role: string; content: string; timestamp: number }[] + createdAt: number + updatedAt: number +} { + const baseTime = 1772492400000 // Slightly after seed prompts + const messages: { id: string; role: string; content: string; timestamp: number }[] = [] + + for (let i = 0; i < nodeDemoPrompts.length; i++) { + const prompt = nodeDemoPrompts[i] + const ts = baseTime + i * 120000 // 2 min between each exchange + messages.push( + { id: `${prompt.id}-q`, role: 'user', content: prompt.userQuery, timestamp: ts }, + { id: `${prompt.id}-a`, role: 'assistant', content: prompt.assistantResponse, timestamp: ts + 5000 }, + ) + } + + return { + id: 'node-demo', + title: 'Exploring My Node', + messages, + createdAt: baseTime, + updatedAt: baseTime + nodeDemoPrompts.length * 120000, + } +} diff --git a/packages/app/src/components/chat/ChatWindow.vue b/packages/app/src/components/chat/ChatWindow.vue index 02f7b2af..03ece060 100644 --- a/packages/app/src/components/chat/ChatWindow.vue +++ b/packages/app/src/components/chat/ChatWindow.vue @@ -177,7 +177,7 @@ defineEmits<{ const chatStore = useChatStore() const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel } = useAI() -const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel() +const { updatePanelFromText, panelOpen, panelFilms, panelTitle, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel() import { useCodeContext } from '@/composables/useCodeContext' import { useVisualViewport } from '@/composables/useVisualViewport' const codeContext = useCodeContext() @@ -364,6 +364,21 @@ async function handleSend(text: string, images: ImageAttachment[] = []) { return } + if (trimmed === '/freefilms') { + const { freeFilms } = await import('@/data/freeFilms') + panelFilms.value = freeFilms + panelTitle.value = 'Free Documentary Films' + panelOpen.value = true + availableTabs.value = ['film', 'prompt'] + setActiveTab('film') + const convId = chatStore.activeConversationId + if (convId) { + chatStore.addMessage(convId, { role: 'user', content: '/freefilms' }) + chatStore.addMessage(convId, { role: 'assistant', content: `Browse ${freeFilms.length} free documentary films from InDeeHub. Click any film to see details, and hit play to watch.` }) + } + return + } + if (trimmed === '/code exit' || trimmed === '/exit') { if (codeContext.isCodeMode.value) { codeContext.exitCodeMode() diff --git a/packages/app/src/components/chat/PromptPalette.vue b/packages/app/src/components/chat/PromptPalette.vue index ab9c5b02..682724cc 100644 --- a/packages/app/src/components/chat/PromptPalette.vue +++ b/packages/app/src/components/chat/PromptPalette.vue @@ -100,6 +100,7 @@ const BUILT_IN_COMMANDS: PaletteCommand[] = [ { id: 'cmd-design', slash: '/design', title: 'Design System', preview: 'Open the design system viewer' }, { id: 'cmd-search', slash: '/search ', title: 'Search', preview: 'Search your content library' }, { id: 'cmd-seed', slash: '/seed', title: 'Seed', preview: 'Load seed conversations for all content types' }, + { id: 'cmd-freefilms', slash: '/freefilms', title: 'Free Films', preview: 'Browse free documentary films from InDeeHub' }, ] const props = defineProps<{ diff --git a/packages/app/src/components/content/BookGrid.vue b/packages/app/src/components/content/BookGrid.vue index ae2ae564..beef0114 100644 --- a/packages/app/src/components/content/BookGrid.vue +++ b/packages/app/src/components/content/BookGrid.vue @@ -53,7 +53,8 @@ @click="$emit('selectBook', book)" >
-
+
+
@@ -102,7 +103,7 @@ + + diff --git a/packages/app/src/composables/useAI.ts b/packages/app/src/composables/useAI.ts index e8064e01..87351203 100644 --- a/packages/app/src/composables/useAI.ts +++ b/packages/app/src/composables/useAI.ts @@ -31,6 +31,47 @@ const podcastContext = mockPodcasts.map((p) => `- [${p.id}] "${p.title}" by ${p.host ?? 'Unknown'}${p.year ? ` (${p.year})` : ''} | ${(p.genres ?? []).join(', ')} | On: ${p.sources.map(x => x.type).join(', ')}` ).join('\n') +// ─── Wavlake catalog context (fetched at runtime) ──────────── +interface WavlakeCatalogTrack { + title?: string + artist?: string + albumTitle?: string + duration?: number +} + +const wavlakeCatalog = ref([]) +let wavlakeFetchedAt = 0 +const WAVLAKE_REFRESH_INTERVAL = 30 * 60 * 1000 // 30 minutes + +async function refreshWavlakeCatalog() { + if (Date.now() - wavlakeFetchedAt < WAVLAKE_REFRESH_INTERVAL && wavlakeCatalog.value.length > 0) return + try { + const BASE = import.meta.env.BASE_URL || '/' + const res = await fetch(`${BASE}api/music/rankings?days=30&limit=40`) + if (!res.ok) return + const data = await res.json() + if (Array.isArray(data)) { + wavlakeCatalog.value = data.map((t: Record) => ({ + title: t.title as string, + artist: t.artist as string, + albumTitle: t.albumTitle as string | undefined, + duration: t.duration as number | undefined, + })) + wavlakeFetchedAt = Date.now() + } + } catch { + // Silently fail — catalog is optional context + } +} + +function buildWavlakeContext(): string { + if (wavlakeCatalog.value.length === 0) return '' + const lines = wavlakeCatalog.value.map((t) => + `- "${t.title}" by ${t.artist}${t.albumTitle ? ` (${t.albumTitle})` : ''}` + ) + return `\n\n**Wavlake trending tracks** (these are confirmed playable — prefer recommending from this list when relevant):\n${lines.join('\n')}` +} + const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library (films, songs, and podcasts). **News/Factual queries:** When the user asks for "news", "latest", "recent", or current information, lead with a direct answer summarizing the news/facts. You MAY add "For deeper coverage:" with [[podcast_ext:...]] tags only. Do NOT use [[song_ext:...]] or [[film_ext:...]] for news queries—podcasts are the appropriate follow-up. Never substitute an answer with only recommendations. @@ -55,7 +96,7 @@ Prioritize Podcasting 2.0–friendly platforms: Fountain.fm, Podcast Index, Cast **Websites / "Best places to check":** When listing resources, places to check online, or websites for the user to visit, use markdown links: [Name](https://full-url). For simple domains use **Name** (domain.com), e.g. **Bitcoin Mailing List** (gnusha.org). -**Music discovery:** For genre-based requests (e.g. "best math rock"), pick from the user's library when relevant, or use [[song_ext:...]] for others. Prioritize indie-friendly platforms: Wavlake, Bandcamp, Internet Archive, SoundCloud, Odysee, Jamendo. +**Music discovery:** All music plays from **Wavlake** — a Lightning-powered, Nostr-native music platform. When recommending songs, prefer tracks from the Wavlake trending list (provided below) since those are confirmed playable. For genre requests, use [[song_ext:Title|Artist]] tags — the UI will search Wavlake automatically. Songs not on Wavlake won't play, so stick to Wavlake artists when you can. The user can zap (tip) artists with Lightning directly through the platform. Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out. @@ -344,6 +385,9 @@ function buildSystemPrompt(chatStore: ReturnType): string { } } + // Append Wavlake catalog context + prompt += buildWavlakeContext() + // Append memory facts const memoryStore = useMemoryStore() prompt += memoryStore.buildMemoryContext() @@ -470,6 +514,9 @@ export async function streamWithModel( export function useAI() { const chatStore = useChatStore() + // Fetch Wavlake catalog on first use (non-blocking) + refreshWavlakeCatalog() + function stopGeneration() { if (currentAbort) { currentAbort.abort() @@ -479,6 +526,9 @@ export function useAI() { } async function sendMessage(userText: string, images?: ImageAttachment[]) { + // Refresh Wavlake catalog if stale (non-blocking, fire-and-forget) + refreshWavlakeCatalog() + const provider = activeProvider.value currentAbort = new AbortController() const signal = currentAbort.signal diff --git a/packages/app/src/composables/useArchy.ts b/packages/app/src/composables/useArchy.ts index c650efca..440289d8 100644 --- a/packages/app/src/composables/useArchy.ts +++ b/packages/app/src/composables/useArchy.ts @@ -1,7 +1,7 @@ import { ref, readonly } from 'vue' import { archyBridge } from '@/services/archyBridge' -type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files' +type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files' | 'bitcoin' interface ArchyApp { id: string @@ -20,10 +20,17 @@ interface ArchyNetworkInfo { } export interface ArchyWalletInfo { - balanceSats?: number - channelCount?: number - totalCapacitySats?: number - nodePubkey?: string + 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 { @@ -34,6 +41,15 @@ export interface ArchyFileEntry { 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) @@ -44,6 +60,7 @@ const systemInfo = ref({}) const networkInfo = ref({}) const walletInfo = ref({}) const fileList = ref([]) +const bitcoinInfo = ref({ available: false }) let cleanups: (() => void)[] = [] /** @@ -125,6 +142,16 @@ export function useArchy() { ) } + if (cats.includes('bitcoin')) { + fetches.push( + archyBridge.requestContext('bitcoin').then((res) => { + if (res.permitted && res.data) { + bitcoinInfo.value = res.data as ArchyBitcoinInfo + } + }).catch(() => {}), + ) + } + if (cats.includes('files')) { fetches.push( archyBridge.requestContext('files').then((res) => { @@ -150,6 +177,26 @@ export function useArchy() { 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).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 { + const res = await requestAction('tail-logs', { appId, lines: String(lines) }) + const data = (res as unknown as Record).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) @@ -165,7 +212,7 @@ export function useArchy() { 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}`) + 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) { @@ -178,21 +225,32 @@ export function useArchy() { sections.push(`**Network:** ${net.connected ? 'Connected' : 'Disconnected'}`) } - if (permissions.value.includes('wallet') && walletInfo.value.balanceSats !== undefined) { + if (permissions.value.includes('wallet') && walletInfo.value.available) { 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(' | ')}`) + 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 recent = files.slice(0, 20) const fileNames = recent.map((f) => f.name).join(', ') - sections.push(`**Files:** ${files.length} files in Nextcloud. Recent: ${fileNames}`) + sections.push(`**Files:** ${files.length} files in Nextcloud. Recent: ${fileNames}\nYou can read file contents by requesting the read-file action with a file path.`) } if (sections.length === 0) return '' @@ -218,10 +276,13 @@ export function useArchy() { networkInfo: readonly(networkInfo), walletInfo: readonly(walletInfo), fileList: readonly(fileList), + bitcoinInfo: readonly(bitcoinInfo), init, destroy, refreshContext, requestAction, + readFile, + tailLogs, buildArchyContext, } } diff --git a/packages/app/src/composables/useImageFallback.ts b/packages/app/src/composables/useImageFallback.ts index 9cc2c3de..5ec6e03e 100644 --- a/packages/app/src/composables/useImageFallback.ts +++ b/packages/app/src/composables/useImageFallback.ts @@ -387,6 +387,24 @@ export async function fetchMusicCover( const cached = musicCoverCache.get(key) if (cached) return cached + // Try Wavlake first — our primary music source + try { + const base = import.meta.env.BASE_URL || '/' + const params = new URLSearchParams({ q: title, title, artist }) + const wlRes = await fetch(`${base}api/music/search?${params}`) + if (wlRes.ok) { + const wlData = (await wlRes.json()) as { coverUrl?: string } + if (wlData.coverUrl) { + musicCoverCache.set(key, wlData.coverUrl) + saveMusicCache() + return wlData.coverUrl + } + } + } catch { + // Fall through to iTunes + } + + // Fallback: iTunes Search API try { const term = `${artist} ${title}`.trim().replace(/\s+/g, '+') const res = await fetch( diff --git a/packages/app/src/composables/useNostr.ts b/packages/app/src/composables/useNostr.ts index 8534531e..8297a063 100644 --- a/packages/app/src/composables/useNostr.ts +++ b/packages/app/src/composables/useNostr.ts @@ -14,6 +14,7 @@ export interface NostrNote { id: string pubkey: string authorName?: string + authorPicture?: string nip05?: string kind: number content: string @@ -69,8 +70,14 @@ const events = shallowRef([]) const isConnected = ref(false) const relayStates = ref([]) +// Profile metadata cache: pubkey → { name, picture, nip05 } +interface ProfileMeta { name?: string; picture?: string; nip05?: string } +const profileCache = new Map() +const pendingProfiles = new Set() + let relays: RelayState[] = [] let subscriptionId: string | null = null +let profileSubId: string | null = null let initialized = false function generateSubId(): string { @@ -92,11 +99,71 @@ function parseEvent(data: unknown): NostrEvent | null { return evt as NostrEvent } +function handleMetadataEvent(evt: NostrEvent) { + if (evt.kind !== 0) return + try { + const meta = JSON.parse(evt.content) as Record + const profile: ProfileMeta = { + name: (meta.display_name ?? meta.name ?? '') as string || undefined, + picture: (meta.picture ?? '') as string || undefined, + nip05: (meta.nip05 ?? '') as string || undefined, + } + profileCache.set(evt.pubkey, profile) + pendingProfiles.delete(evt.pubkey) + + // Update existing notes with this profile data + const updated = events.value.map(n => { + if (n.pubkey !== evt.pubkey) return n + return { + ...n, + authorName: profile.name || n.authorName, + authorPicture: profile.picture, + nip05: profile.nip05 || n.nip05, + } + }) + events.value = updated + } catch { /* malformed kind 0 */ } +} + +function enrichWithProfile(note: NostrNote): NostrNote { + const cached = profileCache.get(note.pubkey) + if (cached) { + return { + ...note, + authorName: cached.name || note.authorName, + authorPicture: cached.picture, + nip05: cached.nip05 || note.nip05, + } + } + return note +} + +function requestProfiles(pubkeys: string[]) { + const needed = pubkeys.filter(pk => !profileCache.has(pk) && !pendingProfiles.has(pk)) + if (needed.length === 0) return + + for (const pk of needed) pendingProfiles.add(pk) + + // Send kind 0 REQ to connected relays + const subId = 'prof-' + Math.random().toString(36).slice(2, 8) + for (const relay of relays) { + if (relay.connected && relay.ws && relay.read) { + relay.ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: needed, limit: needed.length }])) + } + } +} + function addEvent(evt: NostrEvent) { + // Handle metadata events (kind 0) for profile pictures + if (evt.kind === 0) { + handleMetadataEvent(evt) + return + } + const existing = events.value.find(e => e.id === evt.id) if (existing) return - const note: NostrNote = { + const note = enrichWithProfile({ id: evt.id, pubkey: evt.pubkey, authorName: truncatePubkey(evt.pubkey), @@ -104,13 +171,16 @@ function addEvent(evt: NostrEvent) { content: evt.content, created_at: evt.created_at, tags: evt.tags ?? [], - } + }) const newEvents = [...events.value, note] .sort((a, b) => b.created_at - a.created_at) .slice(0, 200) events.value = newEvents + + // Queue profile fetch for this author + requestProfiles([evt.pubkey]) } function connectRelay(relayState: RelayState) { diff --git a/packages/app/src/composables/usePlayer.ts b/packages/app/src/composables/usePlayer.ts index 81a8a9fa..9ded1b0e 100644 --- a/packages/app/src/composables/usePlayer.ts +++ b/packages/app/src/composables/usePlayer.ts @@ -4,11 +4,16 @@ import Plyr from 'plyr' import 'plyr/dist/plyr.css' interface MusicSearchResult { - source: string - type: 'stream' | 'embed' + source: 'wavlake' + type: 'stream' url: string title?: string artist?: string + coverUrl?: string + duration?: number + trackId?: string + albumTitle?: string + wavlakeUrl?: string } // ─── Global singleton state ─────────────────────────────────── @@ -30,7 +35,10 @@ let containerEl: HTMLDivElement | null = null let audioEl: HTMLAudioElement | null = null // Client-side search result cache — avoids re-searching songs +// Null results use a short TTL so transient failures don't stick +const NULL_CACHE_TTL = 2 * 60 * 1000 // 2 minutes const resultCache = new Map() +const nullCacheTimestamps = new Map() // Active search abort controller — cancel stale searches on rapid switching let activeSearchController: AbortController | null = null @@ -45,9 +53,17 @@ export function usePlayer() { // ─── Search with abort + cache ──────────────────────────── async function searchMusic(query: string, title?: string, artist?: string): Promise { - const cacheKey = `${query}|${title ?? ''}|${artist ?? ''}` + const cacheKey = `${title ?? query}|${artist ?? ''}` const cached = resultCache.get(cacheKey) - if (cached !== undefined) return cached + if (cached !== undefined) { + // Positive results stay cached forever; null results expire after TTL + if (cached !== null) return cached + const ts = nullCacheTimestamps.get(cacheKey) + if (ts && Date.now() - ts < NULL_CACHE_TTL) return null + // Expired null — retry + resultCache.delete(cacheKey) + nullCacheTimestamps.delete(cacheKey) + } // Cancel any in-flight search activeSearchController?.abort() @@ -58,13 +74,21 @@ export function usePlayer() { const params = new URLSearchParams({ q: query }) if (title) params.set('title', title) if (artist) params.set('artist', artist) - const res = await fetch(`/api/music/search?${params}`, { + const base = import.meta.env.BASE_URL || '/' + const res = await fetch(`${base}api/music/search?${params}`, { signal: controller.signal, }) + if (!res.ok) { + error.value = `Search failed (${res.status})` + resultCache.set(cacheKey, null) + nullCacheTimestamps.set(cacheKey, Date.now()) + return null + } const data = (await res.json()) as MusicSearchResult & { error?: string } if (data.error || !data.url) { - error.value = data.error ?? 'No playable source found' + error.value = data.error ?? 'Not found on Wavlake' resultCache.set(cacheKey, null) + nullCacheTimestamps.set(cacheKey, Date.now()) return null } const result = data as MusicSearchResult @@ -91,66 +115,55 @@ export function usePlayer() { function initPlayer(result: MusicSearchResult) { if (!containerEl) return - if (result.type === 'stream') { - // Reuse existing audio element if we have one — just change src - if (audioEl && plyrInstance) { - audioEl.src = result.url - audioEl.load() - Promise.resolve(plyrInstance.play()).catch(() => { /* autoplay blocked */ }) - return - } - - // First time — create audio element and Plyr - destroyPlayer() - audioEl = document.createElement('audio') + // Reuse existing audio element if we have one — just change src + if (audioEl && plyrInstance) { audioEl.src = result.url - audioEl.crossOrigin = 'anonymous' - audioEl.preload = 'auto' - containerEl.textContent = '' - containerEl.appendChild(audioEl) - plyrInstance = new Plyr(audioEl, { - controls: [], - autoplay: true, - muted: false, - }) - - plyrInstance.on('ready', () => { - Promise.resolve(plyrInstance!.play()).catch(() => { /* autoplay blocked */ }) - }) - plyrInstance.on('timeupdate', () => { - currentTime.value = plyrInstance!.currentTime ?? 0 - }) - plyrInstance.on('loadedmetadata', () => { - duration.value = plyrInstance!.duration ?? 0 - }) - plyrInstance.on('ended', () => { - isPlaying.value = false - if (currentIndex.value >= 0 && currentIndex.value < queue.value.length - 1) { - playNext() - } - }) - plyrInstance.on('playing', () => { - isPlaying.value = true - isLoading.value = false - }) - plyrInstance.on('pause', () => { - isPlaying.value = false - }) - } else { - // Embed (Odysee etc.) — must recreate iframe - destroyPlayer() - const iframe = document.createElement('iframe') - iframe.src = result.url - iframe.style.width = '100%' - iframe.style.height = '100%' - iframe.style.border = 'none' - containerEl.textContent = '' - containerEl.appendChild(iframe) - plyrInstance = null - audioEl = null - duration.value = 0 - currentTime.value = 0 + audioEl.load() + Promise.resolve(plyrInstance.play()).catch(() => { /* autoplay blocked */ }) + return } + + // First time — create audio element and Plyr + destroyPlayer() + audioEl = document.createElement('audio') + audioEl.src = result.url + audioEl.preload = 'auto' + containerEl.textContent = '' + containerEl.appendChild(audioEl) + plyrInstance = new Plyr(audioEl, { + controls: [], + autoplay: true, + muted: false, + }) + + plyrInstance.on('ready', () => { + Promise.resolve(plyrInstance!.play()).catch(() => { /* autoplay blocked */ }) + }) + plyrInstance.on('timeupdate', () => { + currentTime.value = plyrInstance!.currentTime ?? 0 + }) + plyrInstance.on('loadedmetadata', () => { + duration.value = plyrInstance!.duration ?? 0 + }) + plyrInstance.on('ended', () => { + isPlaying.value = false + if (currentIndex.value >= 0 && currentIndex.value < queue.value.length - 1) { + playNext() + } + }) + plyrInstance.on('playing', () => { + isPlaying.value = true + isLoading.value = false + }) + plyrInstance.on('pause', () => { + isPlaying.value = false + }) + plyrInstance.on('error', (event: Plyr.PlyrEvent) => { + const mediaError = audioEl?.error + console.error('[player] Audio error:', mediaError?.code, mediaError?.message) + isLoading.value = false + error.value = 'Audio failed to load' + }) } function destroyPlayer() { @@ -192,9 +205,17 @@ export function usePlayer() { isLoading.value = false if (!result) { - if (!error.value) error.value = 'No playable source. Tried: Internet Archive, Jamendo, Odysee.' + if (!error.value) error.value = 'Not found on Wavlake' return } + + // Enrich song with Wavlake metadata if available + if (result.coverUrl && currentSong.value && !currentSong.value.coverUrl) { + currentSong.value = { ...currentSong.value, coverUrl: result.coverUrl } + } + if (result.duration && currentSong.value && !currentSong.value.duration) { + currentSong.value = { ...currentSong.value, duration: result.duration } + } error.value = null playableSource.value = result if (containerEl) { diff --git a/packages/app/src/data/freeFilms.ts b/packages/app/src/data/freeFilms.ts new file mode 100644 index 00000000..edf93c80 --- /dev/null +++ b/packages/app/src/data/freeFilms.ts @@ -0,0 +1,543 @@ +// Free documentary films from InDeeHub prototype +// Combined catalog: InDeeHub originals + TopDocumentaryFilms (YouTube) + +import type { Film, FilmSource } from '@aiui/core/types/content' + +const B = import.meta.env.BASE_URL + 'assets/img/films' +const YT_THUMB = 'https://img.youtube.com/vi' + +function yt(embedUrl: string): FilmSource { + return { type: 'youtube', name: 'Free on YouTube', url: embedUrl, icon: '▶️' } +} + +function ih(_title: string): FilmSource { + return { type: 'indeehub', name: 'InDeeHub', url: `https://indeehub.com`, quality: 'HD', icon: '🎬' } +} + +// ── TopDocumentaryFilms — YouTube-streamable ──────────────────── + +const topDocFilms: Film[] = [ + { + id: 'tdf-god-bless-bitcoin', + title: 'God Bless Bitcoin', + year: 2024, + posterUrl: `${B}/posters/topdoc/god-bless-bitcoin.jpg`, + backdropUrl: `${B}/posters/god-bless-bitcoin.webp`, + synopsis: 'A groundbreaking documentary exploring the intersection of faith, finance, and the future of money through the lens of Bitcoin and its transformative impact on religious communities worldwide.', + genres: ['Documentary', 'Bitcoin', 'Religion'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/3XEuqixD2Zg')], + }, + { + id: 'tdf-bitcoin-end-of-money', + title: 'Bitcoin: The End of Money as We Know It', + year: 2015, + posterUrl: `${B}/posters/topdoc/bitcoin-end-of-money.jpg`, + backdropUrl: `${YT_THUMB}/zpNlG3VtcBM/maxresdefault.jpg`, + synopsis: 'Tracing the history of money from barter to Bitcoin, this award-winning documentary examines how decentralized digital currency could upend the global financial system and redefine what money means.', + genres: ['Documentary', 'Bitcoin', 'Economics'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/zpNlG3VtcBM')], + }, + { + id: 'tdf-bitcoin-beyond-bubble', + title: 'Bitcoin: Beyond the Bubble', + year: 2018, + posterUrl: `${B}/posters/topdoc/bitcoin-beyond-bubble.jpg`, + backdropUrl: `${YT_THUMB}/URrmfEu0cZ8/maxresdefault.jpg`, + synopsis: 'An accessible explainer for those intimidated by crypto jargon, tracing currency evolution from precious metals to the dollar to Bitcoin and its promise for the unbanked worldwide.', + genres: ['Documentary', 'Bitcoin', 'Economics'], + rating: 8.3, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/URrmfEu0cZ8')], + }, + { + id: 'tdf-bitcoin-gospel', + title: 'The Bitcoin Gospel', + year: 2015, + posterUrl: `${B}/posters/topdoc/bitcoin-gospel.jpg`, + backdropUrl: `${YT_THUMB}/8zKuoqZLyKg/maxresdefault.jpg`, + synopsis: 'Following entrepreneurs and activists who believe Bitcoin offers an escape from bank and government financial control, examining whether it can truly redefine capitalism globally.', + genres: ['Documentary', 'Bitcoin', 'Economics'], + rating: 7.9, + runtime: 49, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/8zKuoqZLyKg')], + }, + { + id: 'tdf-bitcoin-psyop', + title: 'The Bitcoin Psyop', + year: 2018, + posterUrl: `${B}/posters/topdoc/bitcoin-psyop.jpg`, + backdropUrl: `${YT_THUMB}/XBlai36NorA/maxresdefault.jpg`, + synopsis: 'A short film examining whether Bitcoin and blockchain are genuine technological innovations or hype, and whether they will decentralize power or enable greater government control.', + genres: ['Documentary', 'Bitcoin', 'Conspiracy'], + rating: 7.4, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/XBlai36NorA')], + }, + { + id: 'tdf-missing-cryptoqueen', + title: 'The Missing Cryptoqueen: Dead or Alive?', + year: 2024, + posterUrl: `${B}/posters/topdoc/missing-cryptoqueen.jpg`, + backdropUrl: `${YT_THUMB}/FTnTToWEHvI/maxresdefault.jpg`, + synopsis: 'The extraordinary story of Ruja Ignatova, the self-styled Cryptoqueen who persuaded millions to invest in her cryptocurrency OneCoin before vanishing with billions.', + genres: ['Documentary', 'Crypto', 'Crime'], + rating: 0, + runtime: 53, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/FTnTToWEHvI')], + }, + { + id: 'tdf-billion-dollar-scam', + title: 'The Billion Dollar Scam', + year: 2024, + posterUrl: `${B}/posters/topdoc/billion-dollar-scam.jpg`, + synopsis: 'An investigation into one of the largest financial frauds in history, tracing how billions disappeared through elaborate schemes and the investigators racing to uncover the truth.', + genres: ['Documentary', 'Crypto', 'Crime'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/3QpdU9LS540')], + }, + { + id: 'tdf-money-banking-fed', + title: 'Money, Banking, and The Federal Reserve', + year: 0, + posterUrl: `${B}/posters/topdoc/money-banking-fed.jpg`, + backdropUrl: `${YT_THUMB}/YLYL_NVU1bg/maxresdefault.jpg`, + synopsis: "Featuring Ron Paul, Joseph Salerno, Hans Hoppe, and Lew Rockwell, this film explains the Federal Reserve's operations and history through Austrian economics principles.", + genres: ['Documentary', 'Economics', 'Money'], + rating: 5.3, + runtime: 42, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/YLYL_NVU1bg')], + }, + { + id: 'tdf-american-dream', + title: 'The American Dream', + year: 0, + posterUrl: `${B}/posters/topdoc/american-dream.jpg`, + backdropUrl: `${YT_THUMB}/8NBSwDEf8a8/sddefault.jpg`, + synopsis: 'An animated film examining how money is created and how the Federal Reserve System affects daily life, connecting current economic problems to historical warnings about the financial system.', + genres: ['Documentary', 'Economics', 'Money'], + rating: 8.0, + runtime: 30, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/8NBSwDEf8a8')], + }, + { + id: 'tdf-century-enslavement', + title: 'Century of Enslavement: The History of the Federal Reserve', + year: 0, + posterUrl: `${B}/posters/topdoc/century-enslavement.jpg`, + backdropUrl: `${YT_THUMB}/TmYtPfdwYtY/maxresdefault.jpg`, + synopsis: 'An exhaustive examination of how the Fed was created in 1913 to address banking panics, yet allowed the 2008 financial crisis to occur, with some economists viewing Bitcoin as an alternative.', + genres: ['Documentary', 'Economics', 'Money'], + rating: 8.2, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/TmYtPfdwYtY')], + }, + { + id: 'tdf-money-power-wall-street', + title: 'Money, Power and Wall Street', + year: 2012, + posterUrl: `${B}/posters/topdoc/money-power-wall-street.jpg`, + backdropUrl: `${YT_THUMB}/W-Q9AOp2FW8/maxresdefault.jpg`, + synopsis: 'After almost 80 years, another global financial crisis threatened to bring the world economy to the brink of collapse. This traces the 2008 Wall Street crash and its devastating aftermath.', + genres: ['Documentary', 'Economics', 'Money'], + rating: 8.4, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/W-Q9AOp2FW8')], + }, + { + id: 'tdf-gold-6000-year', + title: "Gold: The Story of Man's 6000 Year Obsession", + year: 2018, + posterUrl: `${B}/posters/topdoc/gold-6000-year.jpg`, + backdropUrl: `${YT_THUMB}/vM8CtejAelM/maxresdefault.jpg`, + synopsis: 'The story of gold is the story of civilizations. What is it about this precious metal that inspires such a level of devotion across millennia of human history?', + genres: ['Documentary', 'Economics', 'History'], + rating: 8.6, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/vM8CtejAelM')], + }, + { + id: 'tdf-debtasized', + title: 'Debtasized', + year: 2024, + posterUrl: `${B}/posters/topdoc/debtasized.jpg`, + synopsis: 'Our reliance on credit has fundamentally altered how we perceive affordability. With a plethora of credit options readily available, the focus has shifted from the total cost to the monthly payment.', + genres: ['Documentary', 'Economics'], + rating: 7.7, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/A63afuvkbmk')], + }, + { + id: 'tdf-crash-next-crisis', + title: 'Crash: Are We Ready for the Next Crisis?', + year: 2019, + posterUrl: `${B}/posters/topdoc/crash-next-crisis.jpg`, + backdropUrl: `${YT_THUMB}/O2pD_y61jx4/maxresdefault.jpg`, + synopsis: 'In 2008, the world was hit by a financial crisis, the worst since the Great Depression. Governments had to bail out banks to prevent total collapse. Are we prepared for the next one?', + genres: ['Documentary', 'Economics'], + rating: 8.2, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/O2pD_y61jx4')], + }, + { + id: 'tdf-pension-gamble', + title: 'The Pension Gamble', + year: 2021, + posterUrl: `${B}/posters/topdoc/pension-gamble.jpg`, + backdropUrl: `${YT_THUMB}/lkOQNPIsO-Q/maxresdefault.jpg`, + synopsis: 'Older civil servant workers in America are worried. Despite steady jobs as firefighters, teachers, and police officers, they can no longer count on one of the most basic financial safety nets.', + genres: ['Documentary', 'Economics'], + rating: 9.2, + runtime: 54, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/lkOQNPIsO-Q')], + }, + { + id: 'tdf-why-americans-poor', + title: 'Why Americans Feel So Poor?', + year: 2023, + posterUrl: `${B}/posters/topdoc/why-americans-poor.jpg`, + backdropUrl: `${YT_THUMB}/kCQiywN7pH4/maxresdefault.jpg`, + synopsis: 'The American middle class has been facing financial challenges and instability, despite being considered a symbol of the American dream in the past.', + genres: ['Documentary', 'Economics', 'Society'], + rating: 8.1, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/kCQiywN7pH4')], + }, + { + id: 'tdf-chain-reaction', + title: 'Chain Reaction', + year: 2022, + posterUrl: `${B}/posters/topdoc/chain-reaction.jpg`, + synopsis: 'Many of us took for granted that online orders arrive in days. Delays were rare until the ongoing supply chain crisis hit the world in full force in 2021.', + genres: ['Documentary', 'Economics'], + rating: 7.8, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/HMmdPgtXUUA')], + }, + { + id: 'tdf-usa-on-brink', + title: 'USA on the Brink', + year: 2020, + posterUrl: `${B}/posters/topdoc/usa-on-brink.jpg`, + backdropUrl: `${YT_THUMB}/G7z1kjkdRxU/maxresdefault.jpg`, + synopsis: 'When the COVID-19 pandemic hit, the magnitude of the economic upheaval it caused was similar to what the USA experienced during the Great Depression.', + genres: ['Documentary', 'Economics'], + rating: 7.3, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/G7z1kjkdRxU')], + }, + { + id: 'tdf-big-four', + title: 'The Big Four: Accounting Firms Under Scrutiny', + year: 2021, + posterUrl: `${B}/posters/topdoc/big-four.jpg`, + backdropUrl: `${YT_THUMB}/C_0XEIFGK5o/maxresdefault.jpg`, + synopsis: "In 2020, Wirecard AG filed for bankruptcy after losing 1.9 billion euros. This film exposes the role of the world's biggest accounting firms in corporate scandals.", + genres: ['Documentary', 'Economics', 'Crime'], + rating: 7.8, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/C_0XEIFGK5o')], + }, + { + id: 'tdf-congo-millionaires', + title: 'Congo: Millionaires of Chaos', + year: 2018, + posterUrl: `${B}/posters/topdoc/congo-millionaires.jpg`, + synopsis: 'The Democratic Republic of Congo is six times the size of Germany and home to over 100 million people. Armed uprisings, political upheavals and violence have marked its history.', + genres: ['Documentary', 'Economics', 'Politics'], + rating: 8.0, + runtime: 0, + director: '', + cast: [], + sources: [], + }, + { + id: 'tdf-economics-of', + title: 'The Economics Of', + year: 2023, + posterUrl: `${B}/posters/topdoc/economics-of.jpg`, + backdropUrl: `${YT_THUMB}/grkHcEyZu04/maxresdefault.jpg`, + synopsis: "Chick-fil-A, IKEA, and others have built devoted customer bases by focusing on excellent service and unique business models. A look at what makes them work.", + genres: ['Documentary', 'Economics'], + rating: 6.8, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/3JKWmWAlMD4')], + }, + { + id: 'tdf-big-business-food', + title: 'Big Business: Food Empires', + year: 2023, + posterUrl: `${B}/posters/topdoc/big-business-food.jpg`, + backdropUrl: `${YT_THUMB}/4ArVvrhhnyI/maxresdefault.jpg`, + synopsis: 'From dry-aged steaks to artisanal burger patties, exploring how third-generation butchers and food entrepreneurs build empires of quality and dedication.', + genres: ['Documentary', 'Economics'], + rating: 8.0, + runtime: 0, + director: '', + cast: [], + sources: [yt('https://www.youtube.com/embed/4ArVvrhhnyI')], + }, + { + id: 'tdf-so-long-superstores', + title: 'So Long, Superstores?', + year: 2021, + posterUrl: `${B}/posters/topdoc/so-long-superstores.jpg`, + synopsis: 'Examining the changing retail landscape and the impact it is having on the traditional superstore model, from big-box to e-commerce.', + genres: ['Documentary', 'Economics'], + rating: 7.5, + runtime: 0, + director: '', + cast: [], + sources: [], + }, +] + +// ── InDeeHub originals — local posters, not yet streamable ────── + +const indeeHubFilms: Film[] = [ + { + id: 'ih-god-bless-bitcoin', + title: 'God Bless Bitcoin', + year: 2024, + posterUrl: `${B}/posters/god-bless-bitcoin.webp`, + synopsis: 'A groundbreaking documentary exploring the intersection of faith, finance, and the future of money through the lens of Bitcoin and its transformative impact on religious communities worldwide.', + genres: ['Documentary', 'Bitcoin', 'Religion'], + rating: 0, + runtime: 90, + director: '', + cast: [], + sources: [ih('God Bless Bitcoin')], + }, + { + id: 'ih-hard-money', + title: 'Hard Money', + year: 0, + posterUrl: `${B}/posters/2b0d7349-c010-47a0-b584-49e1bf86ab2f.png`, + backdropUrl: `${B}/backdrops/2b0d7349-c010-47a0-b584-49e1bf86ab2f.jpg`, + synopsis: 'Understanding sound money principles and the importance of monetary sovereignty in the modern financial system.', + genres: ['Documentary', 'Finance', 'Bitcoin'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [ih('Hard Money')], + }, + { + id: 'ih-bitcoiners', + title: 'Bitcoiners', + year: 0, + posterUrl: `${B}/posters/665a4095-73b9-480d-a0a4-b2aafaf2bce4.png`, + backdropUrl: `${B}/backdrops/665a4095-73b9-480d-a0a4-b2aafaf2bce4.jpg`, + synopsis: 'Meet the passionate individuals building the Bitcoin ecosystem and changing the world of money.', + genres: ['Documentary', 'Bitcoin'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [ih('Bitcoiners')], + }, + { + id: 'ih-lekker-feeling', + title: 'Lekker Feeling: A Bitcoin Ekasi Story', + year: 0, + posterUrl: `${B}/posters/3c113b66-3bb5-4cac-90eb-965ecedc4aa2.png`, + backdropUrl: `${B}/backdrops/3c113b66-3bb5-4cac-90eb-965ecedc4aa2.jpg`, + synopsis: 'A heartwarming documentary about Bitcoin adoption in South African townships and its impact on local communities.', + genres: ['Documentary', 'Bitcoin'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [ih('Lekker Feeling')], + }, + { + id: 'ih-stranded', + title: 'STRANDED: A DIRTY COIN Short', + year: 0, + posterUrl: `${B}/posters/stranded.png`, + backdropUrl: `${B}/backdrops/stranded.png`, + synopsis: 'A companion short film exploring the environmental and energy aspects of Bitcoin mining.', + genres: ['Documentary', 'Bitcoin'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [ih('STRANDED')], + }, + { + id: 'ih-housing-bubble', + title: 'The Housing Bubble', + year: 0, + posterUrl: `${B}/posters/bbdb0178-0b96-4ab5-addf-ba1f029c1cb3.webp`, + backdropUrl: `${B}/backdrops/bbdb0178-0b96-4ab5-addf-ba1f029c1cb3.jpg`, + synopsis: 'An examination of the 2008 financial crisis, mortgage-backed securities, and the devastating impact on American families.', + genres: ['Documentary', 'Finance'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [ih('The Housing Bubble')], + }, + { + id: 'ih-menger', + title: 'Menger. Notes on the margin', + year: 0, + posterUrl: `${B}/posters/584f310b-2269-4b05-a09d-261a0a3c1f78.webp`, + backdropUrl: `${B}/backdrops/584f310b-2269-4b05-a09d-261a0a3c1f78.jpg`, + synopsis: "Exploring Austrian economics, Carl Menger's revolutionary ideas on subjective value, and the foundations of sound economic thinking.", + genres: ['Documentary', 'Economics'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [ih('Menger')], + }, + { + id: 'ih-things-we-carry', + title: 'The Things We Carry', + year: 0, + posterUrl: `${B}/posters/thethingswecarry.webp`, + backdropUrl: `${B}/backdrops/thethingswecarry.webp`, + synopsis: 'A compelling narrative exploring the emotional weight of our past and the baggage we carry through life.', + genres: ['Drama'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [ih('The Things We Carry')], + }, + { + id: 'ih-duel', + title: 'Duel', + year: 0, + posterUrl: `${B}/posters/duel.png`, + backdropUrl: `${B}/backdrops/duel.png`, + synopsis: 'An intense confrontation that tests the limits of human resolve.', + genres: ['Drama', 'Action'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [ih('Duel')], + }, + { + id: 'ih-plastic-money', + title: 'Plastic Money', + year: 0, + posterUrl: `${B}/posters/311f772f-6559-4982-8918-d0f4be9e1b76.webp`, + backdropUrl: `${B}/backdrops/311f772f-6559-4982-8918-d0f4be9e1b76.webp`, + synopsis: 'Examining the credit card industry, debt, and the future of digital payments.', + genres: ['Documentary', 'Finance'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [ih('Plastic Money')], + }, + { + id: 'ih-identity-theft', + title: 'Identity Theft', + year: 0, + posterUrl: `${B}/posters/identity-theft.png`, + backdropUrl: `${B}/backdrops/identity-theft.png`, + synopsis: "A tense thriller about stolen identity, digital security, and the fight to reclaim one's life in the modern age.", + genres: ['Thriller'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [ih('Identity Theft')], + }, + { + id: 'ih-forging-country', + title: 'Forging a Country', + year: 0, + posterUrl: `${B}/posters/forgingacountry.webp`, + backdropUrl: `${B}/backdrops/forgingacountry.webp`, + synopsis: 'The story of nation-building, collective identity, and what it means to create a country from scratch.', + genres: ['Documentary'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [ih('Forging a Country')], + }, + { + id: 'ih-home', + title: 'HOME', + year: 0, + posterUrl: `${B}/posters/home.webp`, + backdropUrl: `${B}/backdrops/home.webp`, + synopsis: 'A poignant exploration of what home means in our modern world and the universal search for belonging.', + genres: ['Drama'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [ih('HOME')], + }, + { + id: 'ih-down-the-pch', + title: 'Down the P.C.H.', + year: 0, + posterUrl: `${B}/posters/down-the-pch.png`, + backdropUrl: `${B}/backdrops/down-the-pch.png`, + synopsis: "A cinematic journey down California's legendary Pacific Coast Highway, exploring freedom and the open road.", + genres: ['Drama'], + rating: 0, + runtime: 0, + director: '', + cast: [], + sources: [ih('Down the P.C.H.')], + }, +] + +// Deduplicate: TopDoc "God Bless Bitcoin" takes priority (has YouTube URL) +export const freeFilms: Film[] = [ + ...topDocFilms, + ...indeeHubFilms.filter(f => f.id !== 'ih-god-bless-bitcoin'), +] diff --git a/packages/app/src/main.ts b/packages/app/src/main.ts index 3e8bd2f9..77d69b78 100644 --- a/packages/app/src/main.ts +++ b/packages/app/src/main.ts @@ -5,6 +5,30 @@ import App from './App.vue' import './styles/main.css' import { initializePlugins } from './plugins' +// PWA cache version — bump this when icons or critical assets change +const PWA_CACHE_VERSION = '2' + +// Purge all service worker caches when version changes +;(async () => { + const key = 'aiui-cache-version' + const stored = localStorage.getItem(key) + if (stored !== PWA_CACHE_VERSION) { + localStorage.setItem(key, PWA_CACHE_VERSION) + if ('caches' in window) { + const names = await caches.keys() + await Promise.all(names.map((name) => caches.delete(name))) + } + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations() + await Promise.all(registrations.map((r) => r.unregister())) + } + if (stored !== null) { + // Only reload if this is an upgrade, not a fresh install + window.location.reload() + } + } +})() + // Capture embedded flag before router init (survives SPA navigation) // Only embedded when explicitly requested via ?embedded param const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded') @@ -33,6 +57,11 @@ const router = createRouter({ name: 'conversation-viewer', component: () => import('./pages/ConversationViewerPage.vue'), }, + { + path: '/guide', + name: 'guide', + component: () => import('./pages/GuidePage.vue'), + }, ], }) diff --git a/packages/app/src/pages/ChatPage.vue b/packages/app/src/pages/ChatPage.vue index f0bdf243..bb365fe8 100644 --- a/packages/app/src/pages/ChatPage.vue +++ b/packages/app/src/pages/ChatPage.vue @@ -1,6 +1,6 @@