Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
<template>
<div class="h-full flex flex-col bg-[#0a0a0a]">
<!-- Header -->
<header class="glass shrink-0 px-4 py-3 flex items-center gap-3 border-b border-white/5">
<router-link
to="/"
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
aria-label="Back to chat"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</router-link>
<h1 class="text-white/90 text-base font-medium truncate">Files</h1>
</header>
<!-- Breadcrumb -->
<nav class="px-4 py-2 flex items-center gap-1 text-xs text-white/40 shrink-0">
<button
class="hover:text-white/70 transition-colors min-h-[28px] px-1"
@click="backToProjects"
>
Projects
</button>
<template v-if="currentProject">
<span class="text-white/20">/</span>
<span class="text-white/60 min-h-[28px] px-1 flex items-center">
{{ currentProject.name }}
</span>
</template>
</nav>
<!-- Content -->
<main class="flex-1 overflow-hidden flex">
<!-- File tree / project list -->
<div class="flex-1 overflow-y-auto px-2 py-2">
<div v-if="loading" class="flex items-center justify-center h-32">
<span class="text-sm text-white/50">Loading...</span>
</div>
<div v-else-if="error" class="flex items-center justify-center h-32">
<span class="text-sm text-red-400/70">{{ error }}</span>
</div>
<!-- Project list -->
<div v-else-if="!currentProject" class="space-y-1">
<button
v-for="project in projects"
:key="project.path"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left hover:bg-white/5 transition-colors group"
@click="openProject(project)"
>
<svg class="w-5 h-5 text-white/30 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm text-white/80 truncate group-hover:text-white/90">{{ project.name }}</p>
<p class="text-xs text-white/25 truncate">{{ project.language }}</p>
</div>
</button>
</div>
<!-- File tree -->
<FileTree
v-else
:items="treeItems"
@select-file="openFile"
/>
</div>
<!-- File preview (desktop sidebar) -->
<aside
v-if="previewFile && !isMobile"
class="w-[400px] xl:w-[500px] border-l border-white/5 overflow-y-auto shrink-0"
>
<FilePreview :file="previewFile" @close="previewFile = null" />
</aside>
</main>
<!-- Mobile file preview overlay -->
<Teleport to="body">
<div
v-if="previewFile && isMobile"
class="fixed inset-0 z-50 bg-[#0a0a0a] overflow-y-auto"
>
<FilePreview :file="previewFile" @close="previewFile = null" />
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import FileTree from '@/components/browse/FileTree.vue'
import FilePreview from '@/components/browse/FilePreview.vue'
import { apiFetch } from '@/utils/api-fetch'
interface FileEntry {
name: string
path: string
isDirectory: boolean
children?: FileEntry[]
}
interface Project {
name: string
path: string
language: string
}
interface PreviewData {
name: string
path: string
content: string
size: number
}
const projects = ref<Project[]>([])
const treeItems = ref<FileEntry[]>([])
const loading = ref(true)
const error = ref('')
const currentProject = ref<Project | null>(null)
const previewFile = ref<PreviewData | null>(null)
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 1024)
async function loadProjects() {
loading.value = true
error.value = ''
try {
const res = await apiFetch('/api/fs/list')
if (!res.ok) throw new Error(`Failed to load: ${res.status}`)
const data = await res.json()
projects.value = data.projects ?? []
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load projects'
} finally {
loading.value = false
}
}
async function openProject(project: Project) {
currentProject.value = project
previewFile.value = null
loading.value = true
error.value = ''
try {
const res = await apiFetch(`/api/fs/tree?path=${encodeURIComponent(project.path)}`)
if (!res.ok) throw new Error(`Failed to load: ${res.status}`)
const data = await res.json()
treeItems.value = data.files ?? []
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load files'
} finally {
loading.value = false
}
}
function backToProjects() {
currentProject.value = null
previewFile.value = null
treeItems.value = []
}
async function openFile(entry: FileEntry) {
if (!currentProject.value) return
const absolutePath = currentProject.value.path + '/' + entry.path
try {
const res = await apiFetch(`/api/fs/read?path=${encodeURIComponent(absolutePath)}`)
if (!res.ok) {
if (res.status === 413) {
error.value = 'File too large to preview (max 1MB)'
return
}
throw new Error(`Failed to read: ${res.status}`)
}
const data = await res.json()
previewFile.value = {
name: entry.name,
path: entry.path,
content: data.content,
size: data.size,
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to read file'
}
}
function onResize() { windowWidth.value = window.innerWidth }
onMounted(() => {
loadProjects()
window.addEventListener('resize', onResize)
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
})
</script>
+679
View File
@@ -0,0 +1,679 @@
<template>
<div
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
:class="[]"
:style="isDark
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
: isEmbedded
? { background: 'transparent' }
: { backgroundColor: '#f5f4f1' }"
>
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
<!-- Desktop layout -->
<div
class="flex-1 flex h-full p-3 md:p-4 gap-3 md:gap-4 transition-[padding] duration-200"
:class="[isMobile ? 'hidden' : '']"
:style="playerActive && !isMobile ? { paddingBottom: '76px' } : {}"
>
<!-- Content surface (main area) -->
<main
class="flex-1 min-w-0 path-glass-card overflow-hidden flex relative panel-slide-in"
:class="[
isWideDesktop
? (panelSide === 'left' ? 'order-2' : 'order-1')
: (panelSide === 'left' ? 'order-last' : 'order-first'),
!isWideDesktop && hasDetailOpen && !hasGridContent && 'detail-active'
]"
style="animation-delay: 0.1s"
>
<!-- Grid/list panel (on wide desktop: always show grid; on regular: show when no detail) -->
<div
v-if="panelOpen && hasGridContent && (isWideDesktop || !hasDetailOpen)"
class="flex-1 min-w-0 flex flex-col relative"
>
<CloseButton @click="closePanel" />
<div
class="shrink-0 flex items-center gap-2 px-4 pr-12 py-3"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex flex-wrap gap-1.5 flex-1 min-w-0 justify-center">
<button
v-for="tab in availableTabs"
:key="tab"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeTab === tab
? 'nav-tab-active'
: isDark
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
@click="setActiveTab(tab)"
>
{{ tabLabel(tab) }}
</button>
</div>
</div>
<ErrorBoundary title="Content failed to load">
<ContentGridView
:active-tab="activeTab"
:is-wide-desktop="isWideDesktop"
:is-mobile="isMobile"
:panel-films="panelFilms"
:panel-books="panelBooks"
:panelTVSeries="panelTVSeries"
:panel-images="panelImages"
:panel-places="panelPlaces"
:panel-songs="panelSongs"
:panel-podcasts="panelPodcasts"
:panel-web-results="panelWebResults"
:panel-websites="panelWebsites"
:panel-magazine-sections="panelMagazineSections"
:panel-magazine-hero-image="panelMagazineHeroImage"
:panel-recipes="panelRecipes"
:panel-apps="panelApps"
:panel-title="panelTitle"
:panel-query="panelQuery"
:panel-response-text="panelResponseText"
@close="closePanel"
/>
</ErrorBoundary>
</div>
<!-- Detail replaces grid on regular desktops -->
<div v-else-if="!isWideDesktop && panelOpen && hasDetailOpen" class="flex-1 min-w-0 flex flex-col">
<CloseButton @click="hasGridContent ? closeAllDetails() : closePanel()" />
<ErrorBoundary title="Detail view error">
<DetailView />
</ErrorBoundary>
</div>
<!-- Loading state -->
<ContextLoader
v-else-if="chatStore.isStreaming"
:context-type="loaderContextType"
>
<template #header-actions>
<CloseButton @click="closePanel" />
</template>
</ContextLoader>
<!-- Empty state -->
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center space-y-4 animate-fade-up max-w-sm px-6">
<div class="empty-state-icon w-20 h-20 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
<span class="text-3xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'">✦</span>
</div>
<div>
<h2 class="text-lg font-bold mb-1"
:class="isDark ? 'text-white/80' : 'text-gray-800'">
Content Surface
</h2>
<p class="text-sm leading-relaxed"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
Ask about films, songs, or podcasts in the chat to see rich content here.
</p>
</div>
</div>
</div>
</main>
<!-- Detail panel (wide desktop only — persistent, no close/back) -->
<div
v-if="isWideDesktop"
class="flex-1 min-w-0 path-glass-card overflow-hidden flex flex-col order-3 detail-persistent panel-slide-in"
style="animation-delay: 0.2s"
>
<template v-if="hasDetailOpen">
<ErrorBoundary title="Detail view error">
<DetailView />
</ErrorBoundary>
</template>
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center space-y-4 max-w-[200px] px-4">
<div class="empty-state-icon w-16 h-16 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
<span class="text-2xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'">✦</span>
</div>
<div>
<h2 class="text-sm font-semibold mb-1"
:class="isDark ? 'text-white/60' : 'text-gray-600'">
Awaiting Context
</h2>
<p class="text-xs leading-relaxed"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
Select an item to see details here.
</p>
</div>
</div>
</div>
</div>
<!-- Chat panel -->
<aside
class="relative z-[100] w-80 xl:w-96 shrink-0 flex flex-col path-glass-card overflow-visible panel-slide-in"
style="animation-delay: 0s"
:class="isWideDesktop
? (panelSide === 'left' ? 'order-1' : 'order-2')
: (panelSide === 'left' ? 'order-first' : 'order-last')"
>
<ErrorBoundary title="Chat error">
<ChatWindow
:side="panelSide"
@switch-side="chatStore.switchSide()"
/>
</ErrorBoundary>
</aside>
</div>
<!-- Mobile layout -->
<div v-if="isMobile" class="flex-1 flex flex-col min-h-0">
<!-- Chat view -->
<div v-show="mobileTab === 'chat'" class="flex-1 min-h-0 flex flex-col p-2 pb-0">
<div class="flex-1 min-h-0 path-glass-card flex flex-col rounded-2xl overflow-hidden">
<ErrorBoundary title="Chat error">
<ChatWindow
variant="standalone"
:show-close="false"
/>
</ErrorBoundary>
</div>
</div>
<!-- Content view -->
<div v-show="mobileTab === 'content'" class="flex-1 min-h-0 flex flex-col p-2 pb-0">
<div class="flex-1 min-h-0 path-glass-card flex flex-col rounded-2xl overflow-hidden">
<template v-if="panelOpen">
<div class="shrink-0 flex items-center gap-2 px-3 pt-2 pb-1 overflow-x-auto scrollbar-hide">
<button
class="p-1.5 rounded-lg transition-colors shrink-0"
:class="isDark
? 'text-white/50 hover:text-white/80 hover:bg-white/5'
: 'text-gray-400 hover:text-gray-700 hover:bg-black/5'"
@click="mobileTab = 'chat'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
v-for="tab in availableTabs"
:key="tab"
class="text-xs px-2.5 py-1.5 rounded-lg font-medium whitespace-nowrap transition-all duration-150"
:class="activeTab === tab
? 'nav-tab-active'
: isDark
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
@click="setActiveTab(tab)"
>
{{ tabLabel(tab) }}
</button>
</div>
<ErrorBoundary title="Content failed to load">
<ContentGridView
:active-tab="activeTab"
:is-wide-desktop="isWideDesktop"
:is-mobile="isMobile"
:panel-films="panelFilms"
:panel-books="panelBooks"
:panelTVSeries="panelTVSeries"
:panel-images="panelImages"
:panel-places="panelPlaces"
:panel-songs="panelSongs"
:panel-podcasts="panelPodcasts"
:panel-web-results="panelWebResults"
:panel-websites="panelWebsites"
:panel-magazine-sections="panelMagazineSections"
:panel-magazine-hero-image="panelMagazineHeroImage"
:panel-recipes="panelRecipes"
:panel-apps="panelApps"
:panel-title="panelTitle"
:panel-query="panelQuery"
@close="closePanel"
/>
</ErrorBoundary>
</template>
<template v-else-if="chatStore.isStreaming">
<ContextLoader :context-type="loaderContextType" />
</template>
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center space-y-3 px-6">
<div class="empty-state-icon w-16 h-16 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
<span class="text-2xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'">✦</span>
</div>
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
Ask about something in the chat to see content here.
</p>
</div>
</div>
</div>
</div>
<!-- Context view (detail panel) -->
<div v-show="mobileTab === 'context'" class="flex-1 min-h-0 flex flex-col p-2 pb-0">
<div class="flex-1 min-h-0 path-glass-card flex flex-col rounded-2xl overflow-hidden">
<template v-if="hasDetailOpen">
<div class="flex-1 min-h-0 flex flex-col">
<ErrorBoundary title="Detail view error">
<DetailView />
</ErrorBoundary>
</div>
<!-- Navigation bar (skip for magazine sections which have their own) -->
<div
v-if="!selectedMagazineSection && contextItems.length > 1"
class="shrink-0 flex items-center justify-between px-4 py-2"
:style="isDark
? 'border-top: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-top: 1px solid rgba(0, 0, 0, 0.06)'"
>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition-colors"
:class="isDark
? 'text-white/50 hover:text-white/80 hover:bg-white/5'
: 'text-black/40 hover:text-black/70 hover:bg-black/5'"
@click="navigateContext('prev')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Prev
</button>
<span class="text-xs font-mono tabular-nums"
:class="isDark ? 'text-white/25' : 'text-black/25'">
{{ currentContextIndex + 1 }}/{{ contextItems.length }}
</span>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition-colors"
:class="isDark
? 'text-white/50 hover:text-white/80 hover:bg-white/5'
: 'text-black/40 hover:text-black/70 hover:bg-black/5'"
@click="navigateContext('next')"
>
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</template>
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center space-y-3 px-6">
<div class="empty-state-icon w-16 h-16 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
<svg class="w-7 h-7" :class="isDark ? 'text-white/30' : 'text-gray-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
Tap an item in Content to view details here.
</p>
</div>
</div>
</div>
</div>
<!-- Inline player bar on mobile (above tab bar) -->
<PlayerBar variant="inline" compact />
<!-- Bottom tab bar (iOS HIG: 49pt + safe area + 24px margin) -->
<div
v-show="!isKeyboardOpen"
class="shrink-0 pt-3 pb-3"
:style="{ paddingBottom: 'calc(12px + env(safe-area-inset-bottom, 0px))' }"
>
<div class="flex items-center h-[49px] px-2 gap-1">
<button
class="flex-1 flex flex-col items-center justify-center h-[49px] min-h-[44px] rounded-xl text-[10px] font-medium tracking-wide transition-all duration-150 gap-0.5"
:class="mobileTab === 'chat'
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
@click="mobileTab = 'chat'"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span>Chat</span>
</button>
<button
class="flex-1 flex flex-col items-center justify-center h-[49px] min-h-[44px] rounded-xl text-[10px] font-medium tracking-wide transition-all duration-150 relative gap-0.5"
:class="mobileTab === 'content'
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
@click="mobileTab = 'content'"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<span>Content</span>
<span
v-if="panelOpen && mobileTab === 'chat'"
class="absolute top-1.5 right-[28%] w-1.5 h-1.5 rounded-full bg-accent"
/>
</button>
<button
class="flex-1 flex flex-col items-center justify-center h-[49px] min-h-[44px] rounded-xl text-[10px] font-medium tracking-wide transition-all duration-150 relative gap-0.5"
:class="mobileTab === 'context'
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
@click="mobileTab = 'context'"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span>Context</span>
<span
v-if="hasDetailOpen && mobileTab !== 'context'"
class="absolute top-1.5 right-[28%] w-1.5 h-1.5 rounded-full bg-accent"
/>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useTheme } from '@/composables/useTheme'
import { useAI } from '@/composables/useAI'
import { useContentPanel, type ContentTab } from '@/composables/useContentPanel'
import { useVisualViewport } from '@/composables/useVisualViewport'
import ChatWindow from '@/components/chat/ChatWindow.vue'
import ContentGridView from '@/components/content/ContentGridView.vue'
import DetailView from '@/components/content/DetailView.vue'
import CloseButton from '@/components/content/CloseButton.vue'
import ContextLoader from '@/components/content/ContextLoader.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import PlayerBar from '@/components/player/PlayerBar.vue'
import { usePlayer } from '@/composables/usePlayer'
import { useCodeContext } from '@/composables/useCodeContext'
const { hasTrack: playerActive } = usePlayer()
const chatStore = useChatStore()
const { activeFile: codeActiveFile, isCodeMode, exitCodeMode, clearActiveFile: clearCodeFile } = useCodeContext()
const { isDark } = useTheme()
// Detect if running inside Archy's iframe — transparent bg, hide player bar
const isEmbedded = !!(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__
// Background image URL — use BASE_URL for correct path in all deployments
// 2912×1632 jpg was 1052K — visible as a slow paint over Tailscale/Tor.
// The webp (1920w, q82) is 478K with no visible difference behind glass.
const bgImageUrl = `${import.meta.env.BASE_URL}assets/img/bg-intro-3.webp`
useAI()
const {
panelOpen,
panelFilms,
panelBooks,
panelTVSeries,
panelImages,
panelPlaces,
panelSongs,
panelPodcasts,
panelWebResults,
panelWebsites,
panelMagazineSections,
panelMagazineHeroImage,
panelRecipes,
panelApps,
panelTitle,
panelQuery,
panelResponseText,
contentType,
activeTab,
availableTabs,
setActiveTab,
selectedFilm,
selectedBook,
selectedTVSeries,
selectedImage,
selectedPlace,
selectedSong,
selectedPodcast,
selectedArticle,
selectedWebsite,
selectedMagazineSection,
selectedDesignSystemItem,
closeFilmDetail,
closeBookDetail,
closeTVSeriesDetail,
closeImageDetail,
closePlaceDetail,
closeSongDetail,
closePodcastDetail,
closeArticleDetail,
closeWebsiteDetail,
closeMagazineSectionDetail,
closePanel,
openFilmDetail,
openBookDetail,
openTVSeriesDetail,
openImageDetail,
openPlaceDetail,
openSongDetail,
openPodcastDetail,
openMagazineSectionDetail,
} = useContentPanel()
const panelSide = computed(() => chatStore.panelSide)
// Mobile detection
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 1024)
const isWideDesktop = computed(() => windowWidth.value >= 1440)
const mobileTab = ref<'chat' | 'content' | 'context'>('chat')
const { isKeyboardOpen } = useVisualViewport()
function onResize() { windowWidth.value = window.innerWidth }
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isCodeMode.value && !isMobile.value) {
exitCodeMode()
}
}
// D-14b (Archipelago phase 02-ui-performance): when the host explicitly asks
// for a chat-first mobile start via ?mobileChat, force the initial mobile tab
// to 'chat' on mount. mobileTab already defaults to 'chat', but ChatPage can
// remount while content/detail selection state (module-singleton refs in
// useContentPanel) survives from a previous mount within the same AIUI
// session — this guarantees the documented default regardless of that carry-
// over. It only runs once, at mount, so it never interferes with the
// hasDetailOpen/panelOpen watchers below responding to real user taps after
// the initial paint.
const startOnMobileChat = new URLSearchParams(window.location.search).has('mobileChat')
onMounted(() => {
window.addEventListener('resize', onResize)
window.addEventListener('keydown', onKeydown)
if (startOnMobileChat && isMobile.value) {
mobileTab.value = 'chat'
}
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
window.removeEventListener('keydown', onKeydown)
})
// Auto-switch to content tab on mobile when panel opens or content changes
watch(panelOpen, (open) => {
if (open && isMobile.value && !isEmbedded) mobileTab.value = 'content'
})
watch(panelTitle, () => {
if (panelOpen.value && isMobile.value && mobileTab.value === 'chat' && !isEmbedded) mobileTab.value = 'content'
})
watch(activeTab, () => {
if (panelOpen.value && isMobile.value && mobileTab.value === 'chat' && !isEmbedded) mobileTab.value = 'content'
})
const hasDetailOpen = computed(() =>
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value ||
selectedImage.value || selectedPlace.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedWebsite.value || selectedMagazineSection.value ||
selectedDesignSystemItem.value ||
(isCodeMode.value && codeActiveFile.value))
)
watch(hasDetailOpen, (open) => {
if (open && isMobile.value) mobileTab.value = 'context'
if (!open && isMobile.value && mobileTab.value === 'context') mobileTab.value = 'content'
})
const hasGridContent = computed(() =>
panelOpen.value && availableTabs.value.length > 0
)
function closeAllDetails() {
closeFilmDetail()
closeBookDetail()
closeTVSeriesDetail()
closeImageDetail()
closePlaceDetail()
closeSongDetail()
closePodcastDetail()
closeArticleDetail()
closeWebsiteDetail()
closeMagazineSectionDetail()
clearCodeFile()
}
// Context navigation — flat list of all detail-openable items
interface ContextNavItem {
open: () => void
}
const contextItems = computed<ContextNavItem[]>(() => {
const items: ContextNavItem[] = []
panelFilms.value.forEach(f => items.push({ open: () => openFilmDetail(f) }))
panelBooks.value.forEach(b => items.push({ open: () => openBookDetail(b) }))
panelTVSeries.value.forEach(s => items.push({ open: () => openTVSeriesDetail(s) }))
panelSongs.value.forEach(s => items.push({ open: () => openSongDetail(s) }))
panelPodcasts.value.forEach(p => items.push({ open: () => openPodcastDetail(p) }))
panelImages.value.forEach(img => items.push({ open: () => openImageDetail(img) }))
panelPlaces.value.forEach(p => items.push({ open: () => openPlaceDetail(p) }))
panelMagazineSections.value.forEach((s, i) => items.push({ open: () => openMagazineSectionDetail(s, i) }))
return items
})
const currentContextIndex = computed(() => {
// Determine which item is currently selected
if (selectedFilm.value) {
const idx = panelFilms.value.indexOf(selectedFilm.value)
return idx >= 0 ? idx : 0
}
if (selectedBook.value) {
const idx = panelBooks.value.indexOf(selectedBook.value)
return idx >= 0 ? panelFilms.value.length + idx : 0
}
if (selectedTVSeries.value) {
const idx = panelTVSeries.value.indexOf(selectedTVSeries.value)
return idx >= 0 ? panelFilms.value.length + panelBooks.value.length + idx : 0
}
if (selectedSong.value) {
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length
const idx = panelSongs.value.indexOf(selectedSong.value)
return idx >= 0 ? base + idx : 0
}
if (selectedPodcast.value) {
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length + panelSongs.value.length
const idx = panelPodcasts.value.indexOf(selectedPodcast.value)
return idx >= 0 ? base + idx : 0
}
if (selectedImage.value) {
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length + panelSongs.value.length + panelPodcasts.value.length
const idx = panelImages.value.indexOf(selectedImage.value)
return idx >= 0 ? base + idx : 0
}
if (selectedPlace.value) {
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length + panelSongs.value.length + panelPodcasts.value.length + panelImages.value.length
const idx = panelPlaces.value.indexOf(selectedPlace.value)
return idx >= 0 ? base + idx : 0
}
if (selectedMagazineSection.value) {
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length + panelSongs.value.length + panelPodcasts.value.length + panelImages.value.length + panelPlaces.value.length
const idx = panelMagazineSections.value.indexOf(selectedMagazineSection.value)
return idx >= 0 ? base + idx : 0
}
return 0
})
function navigateContext(direction: 'prev' | 'next') {
const items = contextItems.value
if (!items.length) return
let idx = currentContextIndex.value
idx += direction === 'next' ? 1 : -1
if (idx < 0) idx = items.length - 1
if (idx >= items.length) idx = 0
closeAllDetails()
items[idx].open()
}
const TAB_LABELS: Record<ContentTab, string> = {
film: 'Films',
book: 'Books',
tvshow: 'TV',
image: 'Images',
place: 'Places',
recipe: 'Recipes',
song: 'Songs',
podcast: 'Podcasts',
news: 'News',
websites: 'Websites',
magazine: 'Brief',
code: 'Code',
'design-system': 'Design',
app: 'Apps',
nostr: 'Nostr',
favorites: 'Favorites',
discover: 'Discover',
prompt: 'Prompt',
}
function tabLabel(tab: ContentTab): string {
return TAB_LABELS[tab] ?? tab
}
const loaderContextType = computed(() => {
if (panelOpen.value) return contentType.value
const lastUser = [...chatStore.messages].reverse().find((m) => m.role === 'user')
const q = (lastUser?.content ?? '').toLowerCase()
// Podcast before song: its words are the specific ones, so "listen to a
// podcast" must not be swallowed by the song rule's bare `listen`.
// Mirrors contentFiltering.ts's preferredFirstTab ordering — these two
// classifiers label the same panel and must not disagree.
if (/\b(podcast|episode)\b/.test(q)) return 'podcast'
if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song'
if (/\b(book|novel|read|author|fiction|nonfiction|memoir)\b/.test(q)) return 'book'
if (/\b(tv show|tv series|series|television|binge|season)\b/.test(q)) return 'tvshow'
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|bar|pub|brunch|lunch|dinner)\b/.test(q)) return 'film'
if (/\b(news|latest|recent|current|what'?s happening|what are people saying)\b/.test(q)) return 'news'
if (/\b(bip|protocol|debate|sentiment|bearish|bull case|macro)\b/.test(q)) return 'magazine'
if (/\b(website|websites|where to check|best places|check online|resources?|sources?)\b/.test(q)) return 'websites'
return contentType.value
})
</script>
<style scoped>
.content-fade-enter-active,
.content-fade-leave-active {
transition: opacity 0.2s ease;
}
.content-fade-enter-from,
.content-fade-leave-to {
opacity: 0;
}
.detail-active {
border-color: transparent !important;
}
/* Hide back buttons in persistent detail panel on wide desktops */
.detail-persistent :deep(button[class*="absolute"][class*="top-3"][class*="left-3"]) {
display: none !important;
}
</style>
@@ -0,0 +1,169 @@
<template>
<div class="min-h-screen bg-[#0a0a0a] text-white">
<!-- Header -->
<header class="sticky top-0 z-10 glass border-b border-white/5">
<div class="max-w-3xl mx-auto px-4 py-3 flex items-center gap-3">
<router-link to="/" class="text-white/40 hover:text-white/70 transition-colors">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</router-link>
<div class="flex-1 min-w-0">
<h1 class="text-sm font-semibold text-white/90 truncate">{{ title }}</h1>
<p class="text-xs text-white/40">
<template v-if="authorName">by {{ authorName }}</template>
<template v-if="publishedAt"> · {{ formattedDate }}</template>
</p>
</div>
<span class="text-xs px-2 py-1 rounded-full bg-white/5 text-white/40">Read-only</span>
</div>
</header>
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center h-64">
<div class="w-6 h-6 rounded-full border-2 border-accent/30 border-t-accent animate-spin" />
</div>
<!-- Error -->
<div v-else-if="error" class="max-w-3xl mx-auto px-4 py-12 text-center">
<p class="text-white/40 text-sm">{{ error }}</p>
<router-link to="/" class="mt-4 inline-block text-accent text-sm hover:underline">
Go to AIUI
</router-link>
</div>
<!-- Content -->
<main v-else class="max-w-3xl mx-auto px-4 py-6 space-y-4">
<div
v-for="(msg, i) in messages"
:key="i"
class="rounded-xl p-4"
:class="msg.role === 'user'
? 'bg-white/[0.03] border border-white/5 ml-8'
: 'mr-8'"
>
<div class="flex items-center gap-2 mb-2">
<span
class="text-xs font-bold uppercase tracking-wider"
:class="msg.role === 'user' ? 'text-accent/70' : 'text-white/30'"
>
{{ msg.role === 'user' ? 'Human' : 'Assistant' }}
</span>
</div>
<div
class="text-sm text-white/80 leading-relaxed whitespace-pre-wrap break-words"
v-text="msg.content"
/>
</div>
</main>
<!-- Footer -->
<footer class="max-w-3xl mx-auto px-4 py-8 text-center">
<p class="text-xs text-white/20">
Shared via AIUI · Powered by Nostr
</p>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useNostr } from '@/composables/useNostr'
const route = useRoute()
const { connect, fetchNote } = useNostr()
const isLoading = ref(true)
const error = ref<string | null>(null)
const title = ref('Shared Conversation')
const authorName = ref<string | null>(null)
const publishedAt = ref<number | null>(null)
const messages = ref<{ role: string; content: string }[]>([])
const formattedDate = computed(() => {
if (!publishedAt.value) return ''
return new Date(publishedAt.value * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
})
function parseMarkdownConversation(markdown: string): { role: string; content: string }[] {
const msgs: { role: string; content: string }[] = []
const lines = markdown.split('\n')
let currentRole = ''
let currentContent: string[] = []
for (const line of lines) {
const userMatch = line.match(/^##?\s*(?:Human|User|You)/)
const assistantMatch = line.match(/^##?\s*(?:Assistant|AI|Claude)/)
if (userMatch || assistantMatch) {
if (currentRole && currentContent.length > 0) {
msgs.push({ role: currentRole, content: currentContent.join('\n').trim() })
}
currentRole = userMatch ? 'user' : 'assistant'
currentContent = []
} else {
currentContent.push(line)
}
}
if (currentRole && currentContent.length > 0) {
msgs.push({ role: currentRole, content: currentContent.join('\n').trim() })
}
// If no structured format detected, treat entire content as a single message
if (msgs.length === 0 && markdown.trim()) {
msgs.push({ role: 'assistant', content: markdown.trim() })
}
return msgs
}
onMounted(async () => {
try {
const nostrAddr = route.params.nostrAddr as string
if (!nostrAddr) {
error.value = 'No Nostr address provided.'
return
}
await connect()
// Attempt to decode the simplified naddr
let decoded: { dTag: string; pubkey: string } | null = null
try {
const raw = atob(nostrAddr)
const parts = raw.split(':')
if (parts.length >= 2) {
decoded = { dTag: parts[0], pubkey: parts[1] }
}
} catch {
// Try fetching as a hex ID
}
if (decoded) {
// Fetch by d-tag from relays
const note = await fetchNote(decoded.dTag)
if (note) {
const titleTag = note.tags.find((t) => t[0] === 'title')
if (titleTag) title.value = titleTag[1]
authorName.value = note.authorName ?? null
publishedAt.value = note.created_at
messages.value = parseMarkdownConversation(note.content)
} else {
error.value = 'Conversation not found on relays.'
}
} else {
error.value = 'Invalid Nostr address format.'
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load conversation.'
} finally {
isLoading.value = false
}
})
</script>
+468
View File
@@ -0,0 +1,468 @@
<template>
<div class="guide-page">
<header class="guide-header">
<button class="back-btn" @click="goBack">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<h1 class="guide-title">AIUI Guide</h1>
<span class="guide-version">v1.0</span>
</header>
<main class="guide-content">
<!-- Intro -->
<section class="guide-section">
<p class="guide-intro">
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.
</p>
</section>
<!-- Node Context -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F4E1;</span>
Node Awareness
</h2>
<p class="section-desc">
When running on your Archipelago node, AIUI automatically knows about your setup. It sees which apps
are installed, your system status, network connectivity, and more. Just ask naturally:
</p>
<div class="example-box">
<div class="example-prompt">"What apps do I have installed?"</div>
<div class="example-prompt">"Is my node connected to the network?"</div>
<div class="example-prompt">"What version of Archipelago am I running?"</div>
</div>
</section>
<!-- File Access -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F4C1;</span>
File Browsing & Reading
</h2>
<p class="section-desc">
AIUI can browse and read text files stored in your Nextcloud instance. It supports common text formats
like <code>.txt</code>, <code>.md</code>, <code>.json</code>, <code>.csv</code>, <code>.log</code>,
<code>.yaml</code>, <code>.conf</code>, and many more.
</p>
<div class="example-box">
<div class="example-prompt">"What files do I have?"</div>
<div class="example-prompt">"Read my config.yaml file"</div>
<div class="example-prompt">"Show me the contents of notes.md"</div>
<div class="example-prompt">"Summarize my todo.txt"</div>
</div>
<div class="info-note">
Files are read up to 100KB. Larger files are truncated with a note. Binary files (images, videos, etc.)
cannot be read as text.
</div>
</section>
<!-- Bitcoin -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x20BF;</span>
Bitcoin Node Status
</h2>
<p class="section-desc">
If you have Bitcoin Core running on your node, AIUI can check the blockchain sync status,
current block height, and mempool information in real-time.
</p>
<div class="example-box">
<div class="example-prompt">"How's my Bitcoin node doing?"</div>
<div class="example-prompt">"What block height am I on?"</div>
<div class="example-prompt">"Is my node fully synced?"</div>
<div class="example-prompt">"How many transactions are in the mempool?"</div>
</div>
</section>
<!-- Lightning -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x26A1;</span>
Lightning Network (LND)
</h2>
<p class="section-desc">
AIUI can query your LND node for channel information, peer count, on-chain and channel balances,
and sync status. Your private keys and macaroons are never exposed.
</p>
<div class="example-box">
<div class="example-prompt">"What's my Lightning balance?"</div>
<div class="example-prompt">"How many channels do I have open?"</div>
<div class="example-prompt">"How many peers is my node connected to?"</div>
<div class="example-prompt">"Is my Lightning node synced?"</div>
</div>
</section>
<!-- App Logs -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F4CB;</span>
App Logs
</h2>
<p class="section-desc">
When an app isn't working right, AIUI can pull recent log output to help diagnose issues.
It reads the last 50 lines by default (up to 200).
</p>
<div class="example-box">
<div class="example-prompt">"Why is Mempool not working?"</div>
<div class="example-prompt">"Show me the Bitcoin Core logs"</div>
<div class="example-prompt">"What errors is Nextcloud showing?"</div>
<div class="example-prompt">"Show me the last 100 lines of LND logs"</div>
</div>
</section>
<!-- App Management -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F4E6;</span>
App Management
</h2>
<p class="section-desc">
AIUI can help you navigate your node, open installed apps, and even install new ones from the
marketplace. It checks what's already installed before recommending anything.
</p>
<div class="example-box">
<div class="example-prompt">"Open Mempool"</div>
<div class="example-prompt">"Install BTCPay Server"</div>
<div class="example-prompt">"Take me to the Settings page"</div>
<div class="example-prompt">"What apps are available to install?"</div>
</div>
</section>
<!-- Chat Features -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F4AC;</span>
Chat Features
</h2>
<div class="feature-grid">
<div class="feature-card">
<h3>Conversation History</h3>
<p>All chats are saved locally on your node. Switch between conversations using the history panel.</p>
</div>
<div class="feature-card">
<h3>Edit Messages</h3>
<p>Click any of your sent messages to edit and re-send them. The AI will regenerate its response.</p>
</div>
<div class="feature-card">
<h3>Branch Conversations</h3>
<p>Fork a conversation at any point to explore a different direction without losing the original thread.</p>
</div>
<div class="feature-card">
<h3>Web Search</h3>
<p>When enabled, AIUI can search the web to find current information and include sources in its responses.</p>
</div>
<div class="feature-card">
<h3>Image Support</h3>
<p>Attach images to your messages for visual questions. AIUI can analyze screenshots, photos, and diagrams.</p>
</div>
<div class="feature-card">
<h3>Dark / Light Theme</h3>
<p>AIUI adapts to your preferred theme. Toggle between dark and light mode in the settings.</p>
</div>
</div>
</section>
<!-- Permissions -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F512;</span>
Privacy & Permissions
</h2>
<p class="section-desc">
AIUI only accesses what you allow. Your node data categories (apps, files, wallet, bitcoin, network, system)
are permission-gated. You control exactly what context AIUI can see through the Archy permissions panel.
</p>
<div class="info-note">
All processing happens through your node's Claude proxy. Your conversations and node data never touch
third-party servers beyond the AI model API itself. Private keys, seeds, and macaroons are never exposed.
</div>
</section>
<!-- Tips -->
<section class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F4A1;</span>
Tips
</h2>
<ul class="tips-list">
<li>Be specific — "Read my bitcoin.conf" works better than "show me config files"</li>
<li>AIUI remembers context within a conversation, so you can ask follow-up questions</li>
<li>If something seems wrong with an app, ask AIUI to check the logs first</li>
<li>You can ask AIUI to explain what a config file does after reading it</li>
<li>Use the history panel to return to previous conversations at any time</li>
</ul>
</section>
<!-- Try It — demo builds only (S7): the demo conversation is
fabricated example node state, demo-site/dev only -->
<section v-if="DEMO_CONTENT_ENABLED" class="guide-section">
<h2 class="section-title">
<span class="section-icon">&#x1F680;</span>
Try It Out
</h2>
<p class="section-desc">
Load a demo conversation that showcases all the node search capabilities described above.
You'll see example exchanges for checking Bitcoin status, reading files, viewing logs, and more.
</p>
<button class="demo-button" :disabled="demoLoading" @click="loadDemo">
<span v-if="demoLoading" class="demo-spinner" />
<span v-else>{{ demoLoaded ? 'View Demo Chat' : 'Load Demo Conversation' }}</span>
</button>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useChatStore } from '@/stores/chat'
import { DEMO_CONTENT_ENABLED } from '@/utils/demoContent'
const router = useRouter()
const chatStore = useChatStore()
const demoLoading = ref(false)
const demoLoaded = ref(false)
function goBack() {
router.push('/')
}
async function loadDemo() {
demoLoading.value = true
try {
await chatStore.loadNodeDemoChat()
demoLoaded.value = true
// Navigate to chat page to see the demo
router.push('/')
} finally {
demoLoading.value = false
}
}
</script>
<style scoped>
.guide-page {
min-height: 100vh;
background: #0a0a0a;
color: rgba(255, 255, 255, 0.9);
font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
}
.guide-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: all 0.2s ease;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.guide-title {
font-size: 18px;
font-weight: 600;
flex: 1;
}
.guide-version {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
padding: 2px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
}
.guide-content {
max-width: 680px;
margin: 0 auto;
padding: 24px 20px 80px;
}
.guide-intro {
font-size: 15px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.7);
}
.guide-section {
margin-bottom: 36px;
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 17px;
font-weight: 600;
margin-bottom: 12px;
color: rgba(255, 255, 255, 0.95);
}
.section-icon {
font-size: 20px;
}
.section-desc {
font-size: 14px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 16px;
}
.section-desc code {
background: rgba(255, 255, 255, 0.08);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
font-family: Menlo, monospace;
color: rgba(255, 255, 255, 0.8);
}
.example-box {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.example-prompt {
padding: 10px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
font-style: italic;
}
.info-note {
padding: 12px 16px;
background: rgba(247, 147, 26, 0.08);
border: 1px solid rgba(247, 147, 26, 0.2);
border-radius: 10px;
font-size: 13px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.6);
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.feature-card {
padding: 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
}
.feature-card h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
color: rgba(255, 255, 255, 0.9);
}
.feature-card p {
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.5);
}
.tips-list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.tips-list li {
padding: 10px 14px;
padding-left: 28px;
position: relative;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.6);
}
.tips-list li::before {
content: '\2022';
position: absolute;
left: 14px;
color: #F7931A;
}
.demo-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 14px 20px;
border-radius: 12px;
border: 1px solid rgba(247, 147, 26, 0.3);
background: rgba(247, 147, 26, 0.1);
color: #F7931A;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.demo-button:hover:not(:disabled) {
background: rgba(247, 147, 26, 0.18);
border-color: rgba(247, 147, 26, 0.5);
transform: translateY(-1px);
}
.demo-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.demo-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(247, 147, 26, 0.3);
border-top-color: #F7931A;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
@@ -0,0 +1,159 @@
<template>
<div class="min-h-full bg-white text-gray-900 font-sans">
<nav class="border-b border-gray-200 bg-white sticky top-0 z-30">
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center">
<span class="text-white text-sm font-bold">A</span>
</div>
<span class="text-lg font-semibold">Acme App</span>
</div>
<div class="flex items-center gap-6 text-sm text-gray-600">
<a href="#" class="hover:text-gray-900">Dashboard</a>
<a href="#" class="hover:text-gray-900">Projects</a>
<a href="#" class="hover:text-gray-900">Settings</a>
<RouterLink to="/" class="text-accent font-medium hover:underline">
← Back to AIUI
</RouterLink>
</div>
</div>
</nav>
<div class="max-w-6xl mx-auto px-6 py-12">
<div class="mb-12">
<h1 class="text-3xl font-bold mb-2">Widget Integration Demo</h1>
<p class="text-gray-500 max-w-2xl">
This page simulates a third-party web application with the AIUI chat widget embedded.
Click the floating button in the bottom corner to open the AI assistant.
You can also switch which side it appears on.
</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-16">
<div v-for="n in 6" :key="n" class="bg-gray-50 rounded-2xl p-6 border border-gray-100">
<div class="w-10 h-10 rounded-xl bg-blue-100 text-blue-600 flex items-center justify-center mb-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 class="font-semibold mb-1">Project {{ n }}</h3>
<p class="text-sm text-gray-500 mb-3">
Sample content card demonstrating how the widget overlays existing UI without disruption.
</p>
<div class="flex items-center gap-2">
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700">Active</span>
<span class="text-xs text-gray-400">Updated 2h ago</span>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-2xl border border-gray-200 p-8 mb-16">
<h2 class="text-xl font-bold mb-4">How to embed AIUI in your app</h2>
<div class="space-y-6 text-sm text-gray-700">
<div>
<h3 class="font-semibold text-gray-900 mb-2">Option 1: Script Tag (simplest)</h3>
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto text-xs"><code>&lt;script src="https://cdn.example.com/aiui-widget.js"&gt;&lt;/script&gt;
&lt;script&gt;
AIUI.init({
apiKey: 'your-openrouter-key',
position: 'bottom-right', // or 'bottom-left'
theme: 'dark',
})
&lt;/script&gt;</code></pre>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">Option 2: Web Component</h3>
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto text-xs"><code>&lt;!-- Load the custom element --&gt;
&lt;script type="module" src="https://cdn.example.com/aiui-element.js"&gt;&lt;/script&gt;
&lt;!-- Use anywhere in your HTML --&gt;
&lt;aiui-chat
api-key="your-key"
position="bottom-right"
theme="dark"
&gt;&lt;/aiui-chat&gt;</code></pre>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">Option 3: npm package (Vue/React/Svelte)</h3>
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto text-xs"><code>npm install @aiui/widget
// Vue 3
import { AIUIWidget } from '@aiui/widget'
app.use(AIUIWidget, {
apiKey: 'your-key',
position: 'bottom-right',
})
// React
import { AIUIProvider } from '@aiui/widget/react'
&lt;AIUIProvider apiKey="your-key" position="bottom-right" /&gt;</code></pre>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">Option 4: iframe (maximum isolation)</h3>
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto text-xs"><code>&lt;iframe
src="https://app.aiui.dev/embed?key=your-key&amp;theme=dark"
style="position:fixed;bottom:80px;right:24px;width:420px;height:640px;
border:none;border-radius:16px;z-index:9999;"
allow="microphone"
&gt;&lt;/iframe&gt;</code></pre>
</div>
</div>
</div>
<div class="bg-gray-50 rounded-2xl border border-gray-200 p-8">
<h2 class="text-xl font-bold mb-4">Technical Architecture</h2>
<div class="grid md:grid-cols-2 gap-8 text-sm text-gray-700">
<div>
<h3 class="font-semibold text-gray-900 mb-2">Script Tag / npm</h3>
<ul class="space-y-1 list-disc list-inside">
<li>Injects a Vue micro-app into a shadow DOM container</li>
<li>Styles are encapsulated — no CSS conflicts with host</li>
<li>Communicates via CustomEvents or a global AIUI API object</li>
<li>Host app can pass context (current page, user info)</li>
<li>Smallest footprint: ~80KB gzipped</li>
</ul>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">Web Component</h3>
<ul class="space-y-1 list-disc list-inside">
<li>Framework-agnostic Custom Element (works in any HTML)</li>
<li>Shadow DOM encapsulation by default</li>
<li>Attributes for configuration, events for callbacks</li>
<li>Can be lazy-loaded with dynamic import()</li>
<li>Works in React, Angular, Svelte, plain HTML</li>
</ul>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">iframe</h3>
<ul class="space-y-1 list-disc list-inside">
<li>Maximum isolation — separate browsing context</li>
<li>Zero risk of CSS/JS conflicts</li>
<li>Communication via postMessage API</li>
<li>API key stays on embedded origin (more secure)</li>
<li>Slightly larger overhead, but simplest integration</li>
</ul>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">Host Communication</h3>
<ul class="space-y-1 list-disc list-inside">
<li>Host can send page context to AI for better answers</li>
<li>Widget can trigger actions in host app via callbacks</li>
<li>Conversation history syncs via encrypted IndexedDB</li>
<li>Deep-link support: <code class="bg-gray-200 px-1 rounded">?aiui=open&q=help</code></li>
</ul>
</div>
</div>
</div>
</div>
<AIUIWidget />
</div>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import AIUIWidget from '@/components/widget/AIUIWidget.vue'
</script>