Archipelago — open-source initial import
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">📡</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">📁</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">₿</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">⚡</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">📋</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">📦</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">💬</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">🔒</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">💡</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">🚀</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><script src="https://cdn.example.com/aiui-widget.js"></script>
|
||||
<script>
|
||||
AIUI.init({
|
||||
apiKey: 'your-openrouter-key',
|
||||
position: 'bottom-right', // or 'bottom-left'
|
||||
theme: 'dark',
|
||||
})
|
||||
</script></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><!-- Load the custom element -->
|
||||
<script type="module" src="https://cdn.example.com/aiui-element.js"></script>
|
||||
|
||||
<!-- Use anywhere in your HTML -->
|
||||
<aiui-chat
|
||||
api-key="your-key"
|
||||
position="bottom-right"
|
||||
theme="dark"
|
||||
></aiui-chat></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'
|
||||
<AIUIProvider apiKey="your-key" position="bottom-right" /></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><iframe
|
||||
src="https://app.aiui.dev/embed?key=your-key&theme=dark"
|
||||
style="position:fixed;bottom:80px;right:24px;width:420px;height:640px;
|
||||
border:none;border-radius:16px;z-index:9999;"
|
||||
allow="microphone"
|
||||
></iframe></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>
|
||||
Reference in New Issue
Block a user