feat(app): apps tab, archy mock data, prompt palette, code browser, detail keys, guide routing

- Add [[app_ext:...]] tag format and rewrite extractApps() for reliable app extraction
- Wire AppsGrid and RecipeGrid into ContentGridView (was missing on wide desktop)
- Add mock Archy node data for standalone dev testing (VITE_MOCK_ARCHY=true)
- Fix PromptPalette: z-50 + opaque bg so slash menu renders above chat content
- Fix detail banner not updating: add :key to all detail components in ContentPanel
- Guide page moved to /guide, chat is now root route, guide auto-selected on first load
- Code browser: click opens file in viewer, separate checkbox for chat context selection
- Restore folder context selector (round checkbox on hover) in FileTreeNode
- Demo projects for prod deployment instead of hardcoded personal paths
- Improve Archy context injection with media breakdown and better error logging
- Add 11 Claude Code skills for efficient development workflows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-07 23:39:41 +00:00
co-authored by Claude Opus 4.6
parent cc875d1c43
commit cb22131909
29 changed files with 911 additions and 138 deletions
@@ -49,13 +49,22 @@
class="relative z-0 flex-1 min-h-0 overflow-y-auto scrollbar-hide"
>
<div v-if="messages.length === 0" class="flex items-center justify-center h-full p-4">
<div class="text-center space-y-3 animate-fade-up">
<div class="text-center space-y-4 animate-fade-up">
<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 text-[#fafafa]"></span>
</div>
<p class="text-sm text-white/30">
Start a conversation
</p>
<router-link
to="/guide"
class="inline-flex items-center gap-1.5 text-xs text-white/25 hover:text-white/50 transition-colors"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
AIUI Guide
</router-link>
</div>
</div>
@@ -1,9 +1,9 @@
<template>
<div v-if="isOpen" class="absolute bottom-full left-2 right-2 mb-1 z-20">
<div v-if="isOpen" class="absolute bottom-full left-2 right-2 mb-1 z-50">
<!-- Variable fill form -->
<div
v-if="selectedTemplate && variables.length > 0"
class="glass-card p-4 space-y-3 animate-scale-in"
class="rounded-2xl bg-[#1a1a1a] border border-white/10 shadow-2xl p-4 space-y-3 animate-scale-in"
>
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold text-white/80">{{ selectedTemplate.title }}</h4>
@@ -35,7 +35,7 @@
<!-- Command + template list -->
<div
v-else
class="glass-card animate-scale-in"
class="rounded-2xl bg-[#1a1a1a] border border-white/10 shadow-2xl max-h-72 overflow-y-auto animate-scale-in"
>
<!-- Commands section -->
<div v-if="filteredCommands.length > 0">
@@ -61,6 +61,18 @@
:title="panelTitle"
@select-podcast="openPodcastDetail"
/>
<RecipeGrid
v-else-if="activeTab === 'recipe'"
:recipes="panelRecipes"
:title="panelTitle"
@select-recipe="openRecipeDetail"
/>
<AppsGrid
v-else-if="activeTab === 'app'"
:apps="panelApps"
:title="panelTitle"
@select-app="openAppDetail"
/>
<ProjectGrid
v-else-if="activeTab === 'code'"
:is-wide-desktop="isWideDesktop"
@@ -87,6 +99,7 @@ import { computed } from 'vue'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import type { WebSearchResult } from '@aiui/core/types/message'
import type { ContentTab, MagazineSection } from '@/composables/useContentPanel'
import type { RecipeData, AppEntry } from '@/composables/contentExtraction'
import { useContentPanel } from '@/composables/useContentPanel'
import { extractMagazineSections, stripContentTags } from '@/composables/contentExtraction'
import FilmGrid from './FilmGrid.vue'
@@ -98,6 +111,8 @@ import SongGrid from './SongGrid.vue'
import PodcastGrid from './PodcastGrid.vue'
import MagazineGrid from './MagazineGrid.vue'
import NewsGrid from './NewsGrid.vue'
import RecipeGrid from './RecipeGrid.vue'
import AppsGrid from './AppsGrid.vue'
import ProjectGrid from './ProjectGrid.vue'
import DesignSystemGrid from './DesignSystemGrid.vue'
import NostrGrid from './NostrGrid.vue'
@@ -117,6 +132,8 @@ const props = defineProps<{
panelWebsites: WebSearchResult[]
panelMagazineSections: MagazineSection[]
panelMagazineHeroImage: string | null
panelRecipes: RecipeData[]
panelApps: AppEntry[]
panelTitle: string
panelQuery: string
panelResponseText?: string
@@ -140,5 +157,7 @@ const {
openPlaceDetail,
openSongDetail,
openPodcastDetail,
openRecipeDetail,
openAppDetail,
} = useContentPanel()
</script>
@@ -58,53 +58,63 @@
<component
:is="filmRenderer?.panelPlay"
v-if="selectedFilm && filmRenderer?.panelPlay"
:key="selectedFilm.id"
:film="selectedFilm"
@back="closeFilmDetail"
/>
<BookDetail
v-else-if="selectedBook"
:key="selectedBook.id"
:book="selectedBook"
@back="closeBookDetail"
/>
<TVSeriesDetail
v-else-if="selectedTVSeries"
:key="selectedTVSeries.id"
:series="selectedTVSeries"
@back="closeTVSeriesDetail"
/>
<component
:is="songRenderer?.panelPlay"
v-else-if="selectedSong && songRenderer?.panelPlay"
:key="selectedSong.id"
:song="selectedSong"
@back="closeSongDetail"
/>
<PodcastDetail
v-else-if="selectedPodcast"
:key="selectedPodcast.id"
:podcast="selectedPodcast"
@back="closePodcastDetail"
/>
<ImageDetail
v-else-if="selectedImage"
:key="selectedImage.url"
:image="selectedImage"
@back="closeImageDetail"
/>
<PlaceDetail
v-else-if="selectedPlace"
:key="selectedPlace.id"
:place="selectedPlace"
@back="closePlaceDetail"
/>
<RecipeDetail
v-else-if="selectedRecipe"
:key="selectedRecipe.title"
:recipe="selectedRecipe"
@back="closeRecipeDetail"
/>
<AppDetail
v-else-if="selectedApp"
:key="selectedApp.id"
:app="selectedApp"
@back="closeAppDetail"
@select-app="openAppDetail"
/>
<ArticleDetail
v-else-if="selectedArticle"
:key="selectedArticle.url"
:article="selectedArticle"
@back="closeArticleDetail"
/>
@@ -1,13 +1,11 @@
<template>
<div>
<button
class="w-full text-left flex items-center gap-1.5 py-1 px-2 rounded-lg text-xs transition-colors"
<div class="group/node">
<div
class="w-full flex items-center gap-1.5 py-1 px-2 rounded-lg text-xs transition-colors cursor-pointer"
:class="[
isSelected
? 'bg-accent/15 text-accent ring-1 ring-accent/30'
: isActive
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: isDark ? 'text-white/60 hover:bg-white/[0.04] hover:text-white/80' : 'text-gray-600 hover:bg-black/[0.03] hover:text-gray-800',
isActive
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: isDark ? 'text-white/60 hover:bg-white/[0.04] hover:text-white/80' : 'text-gray-600 hover:bg-black/[0.03] hover:text-gray-800',
]"
:style="{ paddingLeft: `${depth * 12 + 8}px` }"
@click="handleClick"
@@ -34,11 +32,46 @@
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>
<span class="truncate">{{ entry.name }}</span>
<svg v-if="isSelected" class="w-3 h-3 shrink-0 text-accent ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</button>
<span class="truncate flex-1">{{ entry.name }}</span>
<!-- Context selector checkbox (files: far right, visible on hover or when selected) -->
<button
v-if="!entry.isDirectory"
class="shrink-0 w-4 h-4 rounded-full border flex items-center justify-center transition-all ml-auto"
:class="[
isSelected
? 'bg-accent border-accent text-white'
: isDark
? 'border-white/20 opacity-0 group-hover/node:opacity-100 hover:border-white/40'
: 'border-black/15 opacity-0 group-hover/node:opacity-100 hover:border-black/30',
]"
aria-label="Toggle file for chat context"
@click.stop="handleToggleContext"
>
<svg v-if="isSelected" class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</button>
<!-- Context selector for directories (top-right, visible on hover or when selected) -->
<button
v-if="entry.isDirectory"
class="shrink-0 w-4 h-4 rounded-full border flex items-center justify-center transition-all ml-auto"
:class="[
isDirSelected
? 'bg-accent border-accent text-white'
: isDark
? 'border-white/20 opacity-0 group-hover/node:opacity-100 hover:border-white/40'
: 'border-black/15 opacity-0 group-hover/node:opacity-100 hover:border-black/30',
]"
aria-label="Add folder to chat context"
@click.stop="handleToggleDirContext"
>
<svg v-if="isDirSelected" class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
<!-- Children (when expanded) -->
<div v-if="entry.isDirectory && expanded && entry.children">
@@ -49,6 +82,7 @@
:active-file="activeFile"
:depth="depth + 1"
@select="$emit('select', $event)"
@toggle-context="$emit('toggle-context', $event)"
/>
</div>
</div>
@@ -69,6 +103,7 @@ const { isFileSelected } = useCodeContext()
const emit = defineEmits<{
select: [path: string]
'toggle-context': [path: string]
}>()
const { isDark } = useTheme()
@@ -76,12 +111,22 @@ const expanded = ref(props.depth < 1) // Auto-expand first level
const isActive = computed(() => !props.entry.isDirectory && props.activeFile === props.entry.path)
const isSelected = computed(() => !props.entry.isDirectory && isFileSelected(props.entry.path))
const isDirSelected = computed(() => props.entry.isDirectory && isFileSelected(props.entry.path))
function handleClick() {
if (props.entry.isDirectory) {
expanded.value = !expanded.value
} else {
// Click opens file in code viewer
emit('select', props.entry.path)
}
}
function handleToggleContext() {
emit('toggle-context', props.entry.path)
}
function handleToggleDirContext() {
emit('toggle-context', props.entry.path)
}
</script>
@@ -143,7 +143,8 @@
:entry="entry"
:active-file="activeFile"
:depth="0"
@select="handleFileSelect"
@select="handleFileOpen"
@toggle-context="handleToggleContext"
/>
<div v-if="fileTree.length === 0" class="flex items-center justify-center py-12">
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
@@ -223,11 +224,14 @@ function backToProjects() {
clearActiveFile()
}
function handleFileSelect(filePath: string) {
toggleFileSelection(filePath)
function handleFileOpen(filePath: string) {
openFile(filePath)
}
function handleToggleContext(filePath: string) {
toggleFileSelection(filePath)
}
// New project dialog
function showNewProjectDialog() {
isCreatingProject.value = true
@@ -236,8 +236,87 @@
<!-- Chat defaults -->
<template v-else-if="activeTab === 'chat'">
<!-- M15.10 Default conversation settings -->
<!-- Claude API Key Management -->
<div class="space-y-3">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Claude API Authentication</p>
<!-- Status indicator -->
<div class="flex items-center gap-2 p-3 rounded-xl bg-white/[0.03] border border-white/5">
<div
class="w-2 h-2 rounded-full shrink-0"
:class="store.settings.useOwnApiKey && store.settings.claudeApiKey
? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]'
: 'bg-amber-400 shadow-[0_0_6px_rgba(251,191,36,0.4)]'"
/>
<span class="text-xs text-white/60">
{{ store.settings.useOwnApiKey && store.settings.claudeApiKey
? 'Using your API key'
: 'Using server authentication (OAuth)' }}
</span>
</div>
<!-- Toggle -->
<label class="flex items-center gap-3 p-2.5 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer">
<input
v-model="store.settings.useOwnApiKey"
type="checkbox"
class="rounded accent-[#F7931A]"
/>
<div>
<span class="text-xs text-white/70">Use my own API key</span>
<p class="text-xs text-white/25 mt-0.5">Override server OAuth with your personal Claude API key</p>
</div>
</label>
<!-- API Key input (shown when toggle is on) -->
<div v-if="store.settings.useOwnApiKey" class="space-y-2">
<div class="flex gap-2">
<input
:value="apiKeyDisplay"
:type="showApiKey ? 'text' : 'password'"
placeholder="sk-ant-api03-..."
class="flex-1 px-3 py-2 rounded-lg text-xs bg-white/5 text-white/70 placeholder:text-white/20 outline-none focus:bg-white/10 transition-colors font-mono"
style="font-size: 16px"
@input="onApiKeyInput"
@focus="onApiKeyFocus"
@blur="onApiKeyBlur"
/>
<button
class="px-2 py-2 rounded-lg text-white/30 hover:text-white/60 hover:bg-white/5 transition-colors"
:title="showApiKey ? 'Hide' : 'Show'"
@click="showApiKey = !showApiKey"
>
<svg v-if="!showApiKey" 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 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>
<svg v-else 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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.542-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.542 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
</button>
</div>
<button
v-if="store.settings.claudeApiKey"
class="text-xs px-2 py-1 rounded-md text-red-400/50 hover:text-red-400 hover:bg-red-500/10 transition-colors"
@click="store.settings.claudeApiKey = ''; apiKeyStatus = 'Key removed'"
>
Remove key
</button>
<p v-if="apiKeyStatus" class="text-xs" :class="apiKeyStatusOk ? 'text-emerald-400/70' : 'text-amber-400/70'">
{{ apiKeyStatus }}
</p>
</div>
<!-- Help text -->
<div class="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1.5">
<p class="text-xs text-white/40 font-medium">How to get a Claude API key</p>
<ol class="text-xs text-white/25 space-y-1 list-decimal pl-4">
<li>Go to console.anthropic.com and create an account</li>
<li>Navigate to API Keys in your dashboard</li>
<li>Create a new key (starts with sk-ant-api03-)</li>
<li>Paste it above and enable "Use my own API key"</li>
</ol>
<p class="text-xs text-white/20 mt-2">Your key is stored locally on this device only. Without a key, the server's OAuth authentication is used.</p>
</div>
</div>
<!-- Default conversation settings -->
<div class="space-y-3 mt-6 pt-4 border-t border-white/5">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Default Conversation Settings</p>
<p class="text-xs text-white/30">Applied to all new conversations</p>
@@ -278,7 +357,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { useSettingsStore } from '@/stores/settings'
import type { ContentTab } from '@/composables/contentFiltering'
@@ -430,6 +509,42 @@ async function exportData() {
}
}
// API key management
const showApiKey = ref(false)
const apiKeyEditing = ref(false)
const apiKeyStatus = ref('')
const apiKeyStatusOk = ref(true)
const apiKeyDisplay = computed(() => {
if (apiKeyEditing.value) return store.settings.claudeApiKey
if (!store.settings.claudeApiKey) return ''
if (showApiKey.value) return store.settings.claudeApiKey
const k = store.settings.claudeApiKey
return k.length > 8 ? k.slice(0, 10) + '...' + k.slice(-4) : '****'
})
function onApiKeyInput(e: Event) {
const val = (e.target as HTMLInputElement).value
store.settings.claudeApiKey = val
if (val && val.startsWith('sk-ant-api')) {
apiKeyStatus.value = 'Key saved'
apiKeyStatusOk.value = true
} else if (val && !val.startsWith('sk-ant-')) {
apiKeyStatus.value = 'Key should start with sk-ant-api03-'
apiKeyStatusOk.value = false
} else {
apiKeyStatus.value = ''
}
}
function onApiKeyFocus() {
apiKeyEditing.value = true
}
function onApiKeyBlur() {
apiKeyEditing.value = false
}
// M15.9 Data wipe
const confirmWipe = ref(false)
const wipeApiKeys = ref(false)
@@ -18,6 +18,7 @@ export const BOOK_TAG_RE = /\[\[book:(b?\d+)\]\]/gi
export const BOOK_EXT_RE = /\[\[book_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
export const TV_EXT_RE = /\[\[tv_ext:([^|]+)\|([^|\]]+)(?:\|([^|\]]+))?\]\]/gi
export const PLACE_EXT_RE = /\[\[place_ext:([^|]+)\|([^|]*)(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?\]\]/gi
export const APP_EXT_RE = /\[\[app_ext:([^|]+)\|([^|]*)(?:\|([^|]*))?(?:\|([^|]*))?\]\]/gi
export const MARKDOWN_LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g
const SAFE_URL_SCHEME = /^https?:\/\//i
@@ -1162,8 +1163,15 @@ export function stripPlaceTags(text: string): string {
.trim()
}
export function stripAppTags(text: string): string {
return text
.replace(APP_EXT_RE, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function stripContentTags(text: string): string {
return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(stripTVTags(stripPlaceTags(stripRecipeTags(stripEventTags(text))))))))
return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(stripTVTags(stripPlaceTags(stripRecipeTags(stripEventTags(stripAppTags(text)))))))))
}
export function stripMarkdownLinks(text: string): string {
@@ -1303,16 +1311,56 @@ export function extractBareDomainLinks(text: string): WebSearchResult[] {
// ─── App extraction ──────────────────────────────────────────────
import { APP_DATABASE, type AppEntry } from '@/data/apps'
import { isAppQuery, isNostrQuery } from './contentFiltering'
import { isAppQuery, isAppLikeResponse, isNostrQuery } from './contentFiltering'
export { type AppEntry } from '@/data/apps'
/** Extract apps from [[app_ext:...]] tags first, then fall back to keyword matching */
export function extractApps(text: string, userQuery: string): AppEntry[] {
const lower = text.toLowerCase()
const matched: AppEntry[] = []
const seen = new Set<string>()
let hasExplicitTags = false
// 1. Extract from explicit [[app_ext:Name|Category|Platforms|URL]] tags
const tagRe = new RegExp(APP_EXT_RE.source, 'gi')
let m: RegExpExecArray | null
while ((m = tagRe.exec(text)) !== null) {
hasExplicitTags = true
const name = m[1].trim()
const category = (m[2]?.trim() || 'dev-tool') as AppEntry['category']
const platforms = (m[3]?.trim() || 'web').split(',').map(p => p.trim()) as AppEntry['platforms']
const url = m[4]?.trim() || ''
const id = `ext-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
if (seen.has(id)) continue
seen.add(id)
// Check if this matches a known app in the database
const known = APP_DATABASE.find(a =>
a.name.toLowerCase() === name.toLowerCase() ||
a.keywords.some(k => k.toLowerCase() === name.toLowerCase())
)
if (known && !seen.has(known.id)) {
seen.add(known.id)
matched.push(known)
} else if (!known) {
const description = extractDescriptionForTag(text, m.index!, m[0].length) || `${name}${category} app`
matched.push({
id,
name,
description,
longDescription: description,
category,
platforms,
url,
keywords: [name.toLowerCase()],
})
}
}
// 2. Keyword matching against APP_DATABASE (existing behavior)
const lower = text.toLowerCase()
for (const app of APP_DATABASE) {
if (seen.has(app.id)) continue
const allKeywords = [app.name.toLowerCase(), ...app.keywords.map(k => k.toLowerCase())]
for (const kw of allKeywords) {
if (kw.length < 3) continue
@@ -1328,6 +1376,12 @@ export function extractApps(text: string, userQuery: string): AppEntry[] {
}
}
// 3. Determine if we should surface apps
// If we found explicit tags, always show them
if (hasExplicitTags) {
return matched
}
// Check if query itself mentions a known app name
const queryLower = userQuery.toLowerCase()
const queryMatchesApp = APP_DATABASE.some(app =>
@@ -1335,8 +1389,8 @@ export function extractApps(text: string, userQuery: string): AppEntry[] {
.some(kw => kw.length >= 3 && queryLower.includes(kw))
)
// Surface apps if: app/nostr/known-app query with 1+, or 2+ apps detected in any context
const isAppContext = isAppQuery(userQuery) || isNostrQuery(userQuery) || queryMatchesApp
// Surface apps if: app/nostr/known-app query with 1+, or response looks app-like, or 2+ apps detected
const isAppContext = isAppQuery(userQuery) || isNostrQuery(userQuery) || queryMatchesApp || isAppLikeResponse(text)
if (isAppContext && matched.length >= 1) return matched
if (matched.length >= 2) return matched
return []
+54 -16
View File
@@ -8,6 +8,7 @@ import { useMemoryStore } from '@/stores/memory'
import { useArchy } from '@/composables/useArchy'
import { useCodeContext } from '@/composables/useCodeContext'
import { apiFetch } from '@/utils/api-fetch'
import { useSettingsStore } from '@/stores/settings'
type Provider = 'claude' | 'openrouter' | 'mock'
@@ -95,6 +96,10 @@ Prioritize Podcasting 2.0friendly platforms: Fountain.fm, Podcast Index, Cast
**Places/Restaurants:** When recommending restaurants, cafes, bars, or other places to visit, use [[place_ext:Name|Cuisine|City|Rating|PriceLevel|Address]], e.g. [[place_ext:Sushi Nakazawa|Japanese|New York|4.7|3|23 Commerce St]]. Rating is out of 5, PriceLevel is 1-4 ($ to $$$$). Omit fields you don't know. Write a brief description on the same line.
**Apps/Tools:** When recommending apps, clients, wallets, or tools, use [[app_ext:Name|Category|Platforms|URL]], e.g. [[app_ext:Damus|nostr-client|iOS|https://damus.io]]. Categories: nostr-client, lightning-wallet, bitcoin-wallet, privacy, node, dev-tool. Platforms: comma-separated list of ios,android,web,desktop,cli. Write a brief description on the same line.
**Images:** When sharing or describing images, use standard markdown image syntax: ![Description](https://image-url). Include a brief caption.
**Websites / "Best places to check":** When listing resources, places to check online, or websites for the user to visit, use markdown links: [Name](https://full-url). For simple domains use **Name** (domain.com), e.g. **Bitcoin Mailing List** (gnusha.org).
**Music discovery:** All music plays from **Wavlake** — a Lightning-powered, Nostr-native music platform. When recommending songs, prefer tracks from the Wavlake trending list (provided below) since those are confirmed playable. For genre requests, use [[song_ext:Title|Artist]] tags — the UI will search Wavlake automatically. Songs not on Wavlake won't play, so stick to Wavlake artists when you can. The user can zap (tip) artists with Lightning directly through the platform.
@@ -164,7 +169,8 @@ interface ChatMessage {
/** Build Claude API content array for a message (multimodal when images present) */
function buildClaudeContent(msg: ChatMessage): string | Array<Record<string, unknown>> {
if (!msg.images || msg.images.length === 0) return msg.content
const text = msg.content || '...'
if (!msg.images || msg.images.length === 0) return text
const blocks: Array<Record<string, unknown>> = []
for (const img of msg.images) {
blocks.push({
@@ -172,12 +178,36 @@ function buildClaudeContent(msg: ChatMessage): string | Array<Record<string, unk
source: { type: 'base64', media_type: img.mediaType, data: img.data },
})
}
if (msg.content) {
blocks.push({ type: 'text', text: msg.content })
}
blocks.push({ type: 'text', text })
return blocks
}
/**
* Sanitize message history for the Claude API:
* - Ensure every message has non-empty content
* - Merge consecutive same-role messages to enforce strict alternation
* - Guarantees the resulting array is valid for Claude's messages API
*/
function sanitizeHistory(messages: ChatMessage[]): ChatMessage[] {
const result: ChatMessage[] = []
for (const msg of messages) {
const content = msg.content && msg.content.trim().length > 0 ? msg.content : '...'
const sanitized: ChatMessage = { role: msg.role, content, images: msg.images }
if (result.length > 0 && result[result.length - 1].role === sanitized.role) {
// Merge into previous message of same role to maintain alternation
const prev = result[result.length - 1]
prev.content = prev.content + '\n' + sanitized.content
if (sanitized.images && sanitized.images.length > 0) {
prev.images = [...(prev.images ?? []), ...sanitized.images]
}
} else {
result.push(sanitized)
}
}
return result
}
async function streamMock(
messages: ChatMessage[],
onToken: (text: string) => void,
@@ -213,13 +243,18 @@ async function streamClaude(
): Promise<void> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
// Use vault key if available, proxy uses its own key as fallback
const vaultKey = await getApiKey('claude')
if (vaultKey) {
headers['x-api-key'] = vaultKey
// Use user-provided API key from settings if enabled, then vault, then proxy fallback
const settingsStore = useSettingsStore()
if (settingsStore.settings.useOwnApiKey && settingsStore.settings.claudeApiKey) {
headers['x-api-key'] = settingsStore.settings.claudeApiKey
} else {
const vaultKey = await getApiKey('claude')
if (vaultKey) {
headers['x-api-key'] = vaultKey
}
}
// Build API messages with multimodal content arrays when images present
// Build API messages — sanitize to ensure valid alternation and non-empty content
const apiMessages = messages.map(m => ({
role: m.role,
content: buildClaudeContent(m),
@@ -395,7 +430,6 @@ function buildSystemPrompt(chatStore: ReturnType<typeof useChatStore>): string {
if (chatStore.webSearchEnabled) {
prompt += `
**Web search:** You have access to WebSearch and WebFetch tools. Use them to look up current information, news, and facts when the user asks. You can search the web and fetch page content. Web search is enabled for this session—do not tell the user it is unavailable.`
}
@@ -567,9 +601,11 @@ export function useAI() {
// If client-side search succeeded, don't ask the proxy to search again
const proxyWebSearch = chatStore.webSearchEnabled && !clientSearchSucceeded
const history: ChatMessage[] = chatStore.messages
.filter((m) => m.id !== assistantMsg.id)
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, images: m.images }))
const history: ChatMessage[] = sanitizeHistory(
chatStore.messages
.filter((m) => m.id !== assistantMsg.id)
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, images: m.images }))
)
const onToken = (text: string) => chatStore.appendToLastMessage(cid, text)
const onError = (err: string) => {
@@ -675,9 +711,11 @@ export function useAI() {
}
}
const history: ChatMessage[] = chatStore.messages
.filter((m) => m.id !== assistantMsg.id)
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, images: m.images }))
const history: ChatMessage[] = sanitizeHistory(
chatStore.messages
.filter((m) => m.id !== assistantMsg.id)
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, images: m.images }))
)
const onToken = (text: string) => chatStore.appendToLastMessage(cid, text)
const onError = (err: string) => {
+59 -46
View File
@@ -1,5 +1,9 @@
import { ref, readonly } from 'vue'
import { archyBridge } from '@/services/archyBridge'
import {
mockArchyApps, mockArchySystem, mockArchyNetwork,
mockArchyWallet, mockArchyBitcoin, mockArchyFiles,
} from '@/mocks/archy'
type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files' | 'bitcoin'
@@ -74,6 +78,24 @@ export function useArchy() {
const embedded = !!(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__
isEmbedded.value = embedded
// Dev mock mode: load realistic Archy data for standalone testing
const useMock = import.meta.env.VITE_MOCK_ARCHY === 'true' ||
new URLSearchParams(window.location.search).has('mockArchy')
if (useMock && !embedded) {
isInitialized.value = true
isEmbedded.value = true
permissions.value = ['apps', 'system', 'network', 'wallet', 'bitcoin', 'files']
installedApps.value = mockArchyApps as unknown as ArchyApp[]
systemInfo.value = mockArchySystem
networkInfo.value = mockArchyNetwork
walletInfo.value = mockArchyWallet
bitcoinInfo.value = mockArchyBitcoin
fileList.value = mockArchyFiles
console.log('[AIUI] Mock Archy data loaded for dev testing')
return
}
if (!embedded || !archyBridge.isInArchy()) return
archyBridge.init()
@@ -102,64 +124,42 @@ export function useArchy() {
async function fetchPermittedContext(cats: AIContextCategory[]) {
const fetches: Promise<void>[] = []
function fetchCategory<T>(cat: AIContextCategory, setter: (data: T) => void, validator: (data: unknown) => boolean = () => true) {
return archyBridge.requestContext(cat).then((res) => {
if (!res.permitted) {
console.warn(`[AIUI Archy] ${cat}: not permitted — user should enable in Archy Settings`)
return
}
if (res.data && validator(res.data)) {
setter(res.data as T)
}
}).catch((err) => {
console.warn(`[AIUI Archy] ${cat} fetch failed:`, err?.message ?? err)
})
}
if (cats.includes('apps')) {
fetches.push(
archyBridge.requestContext('apps').then((res) => {
if (res.permitted && Array.isArray(res.data)) {
installedApps.value = res.data as ArchyApp[]
}
}).catch(() => {}),
)
fetches.push(fetchCategory('apps', (data) => { installedApps.value = data as ArchyApp[] }, Array.isArray))
}
if (cats.includes('system')) {
fetches.push(
archyBridge.requestContext('system').then((res) => {
if (res.permitted && res.data) {
systemInfo.value = res.data as ArchySystemInfo
}
}).catch(() => {}),
)
fetches.push(fetchCategory('system', (data) => { systemInfo.value = data as ArchySystemInfo }))
}
if (cats.includes('network')) {
fetches.push(
archyBridge.requestContext('network').then((res) => {
if (res.permitted && res.data) {
networkInfo.value = res.data as ArchyNetworkInfo
}
}).catch(() => {}),
)
fetches.push(fetchCategory('network', (data) => { networkInfo.value = data as ArchyNetworkInfo }))
}
if (cats.includes('wallet')) {
fetches.push(
archyBridge.requestContext('wallet').then((res) => {
if (res.permitted && res.data) {
walletInfo.value = res.data as ArchyWalletInfo
}
}).catch(() => {}),
)
fetches.push(fetchCategory('wallet', (data) => { walletInfo.value = data as ArchyWalletInfo }))
}
if (cats.includes('bitcoin')) {
fetches.push(
archyBridge.requestContext('bitcoin').then((res) => {
if (res.permitted && res.data) {
bitcoinInfo.value = res.data as ArchyBitcoinInfo
}
}).catch(() => {}),
)
fetches.push(fetchCategory('bitcoin', (data) => { bitcoinInfo.value = data as ArchyBitcoinInfo }))
}
if (cats.includes('files')) {
fetches.push(
archyBridge.requestContext('files').then((res) => {
if (res.permitted && Array.isArray(res.data)) {
fileList.value = res.data as ArchyFileEntry[]
}
}).catch(() => {}),
)
fetches.push(fetchCategory('files', (data) => { fileList.value = data as ArchyFileEntry[] }, Array.isArray))
}
await Promise.all(fetches)
@@ -248,14 +248,27 @@ export function useArchy() {
if (permissions.value.includes('files') && fileList.value.length > 0) {
const files = fileList.value
const recent = files.slice(0, 20)
const fileNames = recent.map((f) => f.name).join(', ')
sections.push(`**Files:** ${files.length} files in Nextcloud. Recent: ${fileNames}\nYou can read file contents by requesting the read-file action with a file path.`)
const folders = files.filter(f => f.type === 'folder')
const fileItems = files.filter(f => f.type === 'file')
const images = fileItems.filter(f => /\.(jpg|jpeg|png|gif|webp|svg|heic|heif)$/i.test(f.name))
const videos = fileItems.filter(f => /\.(mp4|mkv|avi|mov|webm)$/i.test(f.name))
const music = fileItems.filter(f => /\.(mp3|flac|wav|ogg|m4a|aac|opus)$/i.test(f.name))
const docs = fileItems.filter(f => /\.(pdf|doc|docx|txt|md|ods|xlsx|csv)$/i.test(f.name))
const parts: string[] = [`${files.length} items`]
if (folders.length > 0) parts.push(`${folders.length} folders (${folders.map(f => f.name).join(', ')})`)
if (images.length > 0) parts.push(`${images.length} images`)
if (videos.length > 0) parts.push(`${videos.length} videos`)
if (music.length > 0) parts.push(`${music.length} audio files`)
if (docs.length > 0) parts.push(`${docs.length} documents`)
const recent = fileItems.slice(0, 15).map(f => f.name).join(', ')
sections.push(`**Files:** ${parts.join(' | ')}\nRecent: ${recent}\nYou can read file contents by requesting the read-file action with a file path.`)
}
if (sections.length === 0) return ''
return `\n\n**Archy Node Context** (this user is running AIUI on their Archipelago node):\n${sections.join('\n')}\n\nYou can help the user manage their node. Available actions: open an app (open-app), install an app (install-app), navigate in Archy (navigate). When recommending apps, check if they're already installed.`
return `\n\n**Archy Node Context** (this user is running AIUI on their Archipelago node):\n${sections.join('\n')}\n\nYou can help the user manage their node, check service status, browse files, and recommend apps. Available actions: open an app (open-app), install an app (install-app), tail app logs (tail-logs), read a file (read-file), navigate in Archy (navigate). When recommending apps, use [[app_ext:...]] tags and check if they're already installed. When discussing the user's files, mention specific files you can see. If the user asks about their photos, videos, or music, reference the file counts above.`
}
/** Clean up on component unmount */
+7 -21
View File
@@ -79,28 +79,14 @@ export function useCodeContext() {
}
function getDemoProjects(): ProjectInfo[] {
// Hardcoded demo list matching actual ~/Projects folder
// Generic demo projects for prod/Archy deployment
return [
{ name: 'AIUI', path: `${PROJECTS_ROOT}/AIUI`, isGit: true, language: 'TypeScript/JavaScript' },
{ name: 'archy', path: `${PROJECTS_ROOT}/archy`, isGit: true, language: 'TypeScript/JavaScript' },
{ name: 'angor', path: `${PROJECTS_ROOT}/angor`, isGit: true, language: 'C#' },
{ name: 'angor-prototype', path: `${PROJECTS_ROOT}/angor-prototype`, isGit: true, language: 'TypeScript/JavaScript' },
{ name: 'archipelago', path: `${PROJECTS_ROOT}/archipelago`, isGit: true, language: 'Unknown' },
{ name: 'archipelago-foundation', path: `${PROJECTS_ROOT}/archipelago-foundation`, isGit: true, language: 'Unknown' },
{ name: 'blossom', path: `${PROJECTS_ROOT}/blossom`, isGit: true, language: 'TypeScript/JavaScript' },
{ name: 'fedimint', path: `${PROJECTS_ROOT}/fedimint`, isGit: true, language: 'Rust' },
{ name: 'Syntopy', path: `${PROJECTS_ROOT}/Syntopy`, isGit: true, language: 'Unknown' },
{ name: 'Syntropy-Institute', path: `${PROJECTS_ROOT}/Syntropy-Institute`, isGit: true, language: 'Unknown' },
{ name: 'LoRaBell', path: `${PROJECTS_ROOT}/LoRaBell`, isGit: true, language: 'Unknown' },
{ name: 'satoshi-services', path: `${PROJECTS_ROOT}/satoshi-services`, isGit: true, language: 'Unknown' },
{ name: 'Proux', path: `${PROJECTS_ROOT}/Proux`, isGit: true, language: 'Unknown' },
{ name: 'KYC', path: `${PROJECTS_ROOT}/KYC`, isGit: true, language: 'Unknown' },
{ name: 'k484', path: `${PROJECTS_ROOT}/k484`, isGit: true, language: 'Unknown' },
{ name: 'tbf', path: `${PROJECTS_ROOT}/tbf`, isGit: true, language: 'Unknown' },
{ name: 'Icon', path: `${PROJECTS_ROOT}/Icon`, isGit: false, language: 'Unknown' },
{ name: 'indeehub-frontend', path: `${PROJECTS_ROOT}/indeehub-frontend`, isGit: true, language: 'TypeScript/JavaScript' },
{ name: 'Indeedhub Prototype', path: `${PROJECTS_ROOT}/Indeedhub Prototype`, isGit: true, language: 'Unknown' },
{ name: '21', path: `${PROJECTS_ROOT}/21`, isGit: true, language: 'Unknown' },
{ name: 'my-lightning-app', path: '/projects/my-lightning-app', isGit: true, language: 'TypeScript/JavaScript' },
{ name: 'node-dashboard', path: '/projects/node-dashboard', isGit: true, language: 'TypeScript/JavaScript' },
{ name: 'btc-price-tracker', path: '/projects/btc-price-tracker', isGit: true, language: 'Python' },
{ name: 'nostr-relay-config', path: '/projects/nostr-relay-config', isGit: true, language: 'Rust' },
{ name: 'channel-monitor', path: '/projects/channel-monitor', isGit: true, language: 'Go' },
{ name: 'backup-scripts', path: '/projects/backup-scripts', isGit: false, language: 'Shell' },
]
}
+68 -16
View File
@@ -53,6 +53,53 @@ export function usePlayer() {
// ─── Search with abort + cache ────────────────────────────
async function searchWavlakeDirect(
query: string,
title?: string,
artist?: string,
signal?: AbortSignal,
): Promise<MusicSearchResult | null> {
const searches: string[] = []
if (title) searches.push(title)
if (title && artist) searches.push(`${title} ${artist}`)
if (artist) searches.push(artist)
if (!title && !artist) searches.push(query)
for (const term of searches) {
try {
const res = await fetch(
`https://wavlake.com/api/v1/content/search?term=${encodeURIComponent(term)}`,
{ signal, headers: { Accept: 'application/json' } },
)
if (!res.ok) continue
const items = (await res.json()) as {
id: string; title?: string; name?: string; type: string
mediaUrl?: string; artist?: string; albumArtUrl?: string
artistArtUrl?: string; duration?: number; albumTitle?: string
}[]
if (!Array.isArray(items)) continue
const tracks = items.filter(i => i.type === 'track' && !!i.mediaUrl)
if (tracks.length === 0) continue
const best = tracks[0]
return {
source: 'wavlake',
type: 'stream',
url: best.mediaUrl!,
title: best.title ?? best.name,
artist: best.artist,
coverUrl: best.albumArtUrl ?? best.artistArtUrl,
duration: best.duration,
trackId: best.id,
albumTitle: best.albumTitle,
wavlakeUrl: `https://wavlake.com/track/${best.id}`,
}
} catch (e) {
if ((e as Error).name === 'AbortError') return null
}
}
return null
}
async function searchMusic(query: string, title?: string, artist?: string): Promise<MusicSearchResult | null> {
const cacheKey = `${title ?? query}|${artist ?? ''}`
const cached = resultCache.get(cacheKey)
@@ -71,6 +118,8 @@ export function usePlayer() {
const controller = new AbortController()
activeSearchController = controller
// Try local API proxy first (works in dev), then Wavlake directly (works in prod)
let result: MusicSearchResult | null = null
try {
const params = new URLSearchParams({ q: query })
if (title) params.set('title', title)
@@ -79,27 +128,30 @@ export function usePlayer() {
const res = await apiFetch(`${base}api/music/search?${params}`, {
signal: controller.signal,
})
if (!res.ok) {
error.value = `Search failed (${res.status})`
resultCache.set(cacheKey, null)
nullCacheTimestamps.set(cacheKey, Date.now())
return null
if (res.ok) {
const data = (await res.json()) as MusicSearchResult & { error?: string }
if (!data.error && data.url) {
result = data as MusicSearchResult
}
}
const data = (await res.json()) as MusicSearchResult & { error?: string }
if (data.error || !data.url) {
error.value = data.error ?? 'Not found on Wavlake'
resultCache.set(cacheKey, null)
nullCacheTimestamps.set(cacheKey, Date.now())
return null
}
const result = data as MusicSearchResult
resultCache.set(cacheKey, result)
return result
} catch (e) {
if ((e as Error).name === 'AbortError') return null
error.value = 'Network error. Is the dev server running?'
}
// Fallback: call Wavlake API directly
if (!result) {
result = await searchWavlakeDirect(query, title, artist, controller.signal)
}
if (!result) {
error.value = 'Not found on Wavlake'
resultCache.set(cacheKey, null)
nullCacheTimestamps.set(cacheKey, Date.now())
return null
}
resultCache.set(cacheKey, result)
return result
}
// ─── Container management ─────────────────────────────────
+5 -5
View File
@@ -42,6 +42,11 @@ const router = createRouter({
name: 'chat',
component: () => import('./pages/ChatPage.vue'),
},
{
path: '/guide',
name: 'guide',
component: () => import('./pages/GuidePage.vue'),
},
{
path: '/widget-demo',
name: 'widget-demo',
@@ -57,11 +62,6 @@ const router = createRouter({
name: 'conversation-viewer',
component: () => import('./pages/ConversationViewerPage.vue'),
},
{
path: '/guide',
name: 'guide',
component: () => import('./pages/GuidePage.vue'),
},
],
})
+79
View File
@@ -0,0 +1,79 @@
/**
* Mock Archy node data for standalone dev/testing.
* Provides realistic data for all 6 context categories so you can test
* the Archy integration without running inside an actual Archipelago node.
*
* Enable with: VITE_MOCK_ARCHY=true or ?mockArchy URL param
*/
import type { ArchyWalletInfo, ArchyBitcoinInfo, ArchyFileEntry } from '@/composables/useArchy'
export interface MockArchyApp {
id: string
name: string
state: 'running' | 'stopped'
status: string
}
export const mockArchyApps: MockArchyApp[] = [
{ id: 'bitcoin-core', name: 'Bitcoin Core', state: 'running', status: 'Synced — Block 893,412' },
{ id: 'lnd', name: 'LND', state: 'running', status: '6 channels, 12 peers' },
{ id: 'mempool', name: 'Mempool', state: 'running', status: 'Healthy' },
{ id: 'btcpay-server', name: 'BTCPay Server', state: 'running', status: 'Healthy' },
{ id: 'nextcloud', name: 'Nextcloud', state: 'running', status: '847 files, 12.4 GB used' },
{ id: 'immich', name: 'Immich', state: 'running', status: '2,341 photos, 89 videos' },
{ id: 'nostr-rs-relay', name: 'nostr-rs-relay', state: 'running', status: '14,203 events' },
{ id: 'home-assistant', name: 'Home Assistant', state: 'stopped', status: 'Stopped' },
{ id: 'searxng', name: 'SearXNG', state: 'running', status: 'Healthy' },
{ id: 'grafana', name: 'Grafana', state: 'running', status: 'Healthy' },
{ id: 'ollama', name: 'Ollama', state: 'stopped', status: 'Stopped' },
]
export const mockArchySystem = {
name: 'Archipelago',
version: '0.9.2',
}
export const mockArchyNetwork = {
connected: true,
}
export const mockArchyWallet: ArchyWalletInfo = {
available: true,
status: 'active',
alias: 'MyNode',
num_active_channels: 6,
num_peers: 12,
synced_to_chain: true,
block_height: 893412,
balance_sats: 1_250_000,
channel_balance_sats: 3_500_000,
pending_open_balance: 0,
}
export const mockArchyBitcoin: ArchyBitcoinInfo = {
available: true,
block_height: 893412,
sync_progress: 1.0,
chain: 'mainnet',
mempool_tx_count: 42_350,
mempool_size: 128_000_000,
}
export const mockArchyFiles: ArchyFileEntry[] = [
{ name: 'Documents', path: '/Documents', type: 'folder' },
{ name: 'Photos', path: '/Photos', type: 'folder' },
{ name: 'Music', path: '/Music', type: 'folder' },
{ name: 'Videos', path: '/Videos', type: 'folder' },
{ name: 'family-reunion-2024.jpg', path: '/Photos/family-reunion-2024.jpg', type: 'file', size: 4_200_000, modified: '2024-12-25' },
{ name: 'sunset-beach.jpg', path: '/Photos/sunset-beach.jpg', type: 'file', size: 3_800_000, modified: '2024-11-15' },
{ name: 'node-setup-guide.pdf', path: '/Documents/node-setup-guide.pdf', type: 'file', size: 2_100_000, modified: '2024-10-01' },
{ name: 'bitcoin-whitepaper.pdf', path: '/Documents/bitcoin-whitepaper.pdf', type: 'file', size: 184_000, modified: '2024-01-03' },
{ name: 'household-budget.ods', path: '/Documents/household-budget.ods', type: 'file', size: 45_000, modified: '2025-02-28' },
{ name: 'birthday-video.mp4', path: '/Videos/birthday-video.mp4', type: 'file', size: 250_000_000, modified: '2025-01-15' },
{ name: 'conference-talk.mp4', path: '/Videos/conference-talk.mp4', type: 'file', size: 180_000_000, modified: '2024-09-20' },
{ name: 'podcast-episode.mp3', path: '/Music/podcast-episode.mp3', type: 'file', size: 45_000_000, modified: '2025-03-01' },
{ name: 'backup-keys.txt', path: '/Documents/backup-keys.txt', type: 'file', size: 1200, modified: '2024-06-15' },
{ name: 'recipes.md', path: '/Documents/recipes.md', type: 'file', size: 8500, modified: '2025-02-14' },
{ name: 'travel-plans.md', path: '/Documents/travel-plans.md', type: 'file', size: 3200, modified: '2025-03-05' },
]
+6
View File
@@ -72,6 +72,8 @@
: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"
@@ -225,6 +227,8 @@
: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"
@@ -415,6 +419,8 @@ const {
panelWebsites,
panelMagazineSections,
panelMagazineHeroImage,
panelRecipes,
panelApps,
panelTitle,
panelQuery,
panelResponseText,
+1 -5
View File
@@ -227,11 +227,7 @@ const demoLoading = ref(false)
const demoLoaded = ref(false)
function goBack() {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
router.push('/')
}
async function loadDemo() {
+10 -2
View File
@@ -465,7 +465,15 @@ export const useChatStore = defineStore('chat', () => {
/** Seed demo conversations (guide + node demo) on first use */
async function seedDemoConversations(): Promise<void> {
if (conversations.value.has('aiui-guide') && conversations.value.has('node-demo')) return
const hasGuide = conversations.value.has('aiui-guide')
const hasDemo = conversations.value.has('node-demo')
if (hasGuide && hasDemo) {
// Already seeded — still select guide if nothing active
if (!activeConversationId.value) {
activeConversationId.value = 'aiui-guide'
}
return
}
try {
const { guideToConversation } = await import('@/__tests__/fixtures/guideConversation')
const { nodeDemoToConversation } = await import('@/__tests__/fixtures/nodeDemoPrompts')
@@ -481,7 +489,7 @@ export const useChatStore = defineStore('chat', () => {
immediateIDBSave(demo)
}
conversations.value = merged
// Show guide on first load
// Show guide conversation on first load
if (!activeConversationId.value) {
activeConversationId.value = guide.id
}
+5
View File
@@ -24,6 +24,9 @@ export interface AppSettings {
defaultPersonaId: string
defaultWebSearch: boolean
defaultShowTokens: boolean
// API key management
claudeApiKey: string
useOwnApiKey: boolean
}
const DEFAULT_SETTINGS: AppSettings = {
@@ -45,6 +48,8 @@ const DEFAULT_SETTINGS: AppSettings = {
defaultPersonaId: '',
defaultWebSearch: false,
defaultShowTokens: false,
claudeApiKey: '',
useOwnApiKey: false,
}
export const useSettingsStore = defineStore('settings', () => {