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
+43
View File
@@ -0,0 +1,43 @@
---
name: add-content-type
description: Scaffold a complete new content type (tag, extraction, grid, detail, prompt)
allowed-tools: Bash(*), Read, Edit, Write, Glob, Grep, Agent
---
Add a complete new content type to AIUI. The user will provide the content type name (e.g., "event", "product", "video").
Follow ALL steps — this is the full pipeline for a content type:
1. **Tag format**: Add a new `[[{type}_ext:Field1|Field2|...]]` regex to `packages/app/src/composables/contentExtraction.ts` alongside the existing ones (FILM_EXT_RE, SONG_EXT_RE, etc.)
2. **Type definition**: Add the TypeScript interface to `packages/core/src/types/content.ts` if it doesn't exist
3. **Extraction function**: Add `extractAll{Type}s(text, userQuery)` to `contentExtraction.ts` following the pattern of `extractAllFilms` or `extractAllBooks`
4. **Strip tags function**: Add `strip{Type}Tags()` and include it in `stripContentTags()`
5. **Query classifier**: Add `is{Type}Query()` and optionally `is{Type}LikeResponse()` to `contentFiltering.ts`
6. **ContentTab type**: Add the new tab name to the `ContentTab` union in `contentFiltering.ts`
7. **Tab filtering**: Update `filterTabsByContext()` and `preferredFirstTab()` in `contentFiltering.ts`
8. **Grid component**: Create `packages/app/src/components/content/{Type}Grid.vue` following the glass-morphism pattern of existing grids (BookGrid.vue is a good template)
9. **Detail component**: Create `packages/app/src/components/content/{Type}Detail.vue` following the pattern of BookDetail.vue
10. **Wire into ContentPanel.vue**: Add import, grid render block, detail render block, and panel state refs
11. **Wire into ContentGridView.vue**: Add import, props, and grid render block
12. **Wire into ChatPage.vue**: Pass the new panel data as props to ContentGridView
13. **Wire into useContentPanel.ts**: Add panel ref, selected ref, open/close functions, extraction call in `updatePanelFromText()`
14. **System prompt**: Add tag format instructions to the `SYSTEM_PROMPT` in `useAI.ts`
15. **Tab label**: Add to `TAB_LABELS` in `ContentPanel.vue`
16. **Verify**: Run `pnpm typecheck` and fix any errors
Report what was created and the tag format to use.
+32
View File
@@ -0,0 +1,32 @@
---
name: add-tool
description: Add a new AI tool (function call) to the Claude proxy for the AI to use
allowed-tools: Bash(*), Read, Edit, Write, Glob, Grep
---
Add a new tool that the AI model can call via Claude's tool_use API. The user will describe what the tool should do (e.g., "search local files", "get app status", "browse media").
## Steps
1. **Read the proxy**: Read `packages/app/server/claude-proxy.ts` to understand the existing tool_use loop and `SEARCH_WEB_TOOL` definition.
2. **Define the tool**: Add a new tool definition following the Claude tool_use format:
```ts
const NEW_TOOL = {
name: 'tool_name',
description: 'What this tool does...',
input_schema: {
type: 'object',
properties: { ... },
required: [...]
}
}
```
3. **Add handler**: In the tool_use loop (where `search_web` calls are handled), add a handler for the new tool name.
4. **Implement backend**: If the tool needs a new API endpoint (e.g., `/api/media/scan`), create a Vite plugin or add a route to the proxy.
5. **Update system prompt**: Add instructions in `useAI.ts` SYSTEM_PROMPT telling the AI when and how to use the new tool.
6. **Verify**: Run `pnpm typecheck` and test the proxy starts without errors.
+37
View File
@@ -0,0 +1,37 @@
---
name: audit-prompts
description: Deep audit of AI system prompts — find gaps, test extraction coverage, verify tag formats
allowed-tools: Bash(*), Read, Glob, Grep, Agent
---
Perform a comprehensive audit of AIUI's AI prompt system. Do NOT make changes — report findings only.
## Steps
1. **Read the full system prompt**: Read `packages/app/src/composables/useAI.ts` and reconstruct the complete system prompt including all dynamic sections (persona, Wavlake, memory, web search, Archy context, code context).
2. **Catalog all tag formats**: List every `[[type:...]]` and `[[type_ext:...]]` format defined in the prompt. Cross-reference with regexes in `contentExtraction.ts`.
3. **Check for gaps**: For each content type in `ContentTab` (contentFiltering.ts), verify:
- Is there a tag format in the system prompt?
- Is there a matching extraction regex?
- Is there a query classifier?
- Is there a grid + detail component?
- Is the tab wired in ContentPanel.vue and ContentGridView.vue?
4. **Test extraction coverage**: Read the seed prompts in `src/__tests__/fixtures/seedPrompts.ts`. For each seed, verify:
- Does the extraction function find the expected number of items?
- Are there edge cases that would break extraction?
5. **Analyze prompt quality**: Check for:
- Conflicting instructions
- Missing edge case handling (e.g., "what if the AI can't find a match?")
- Overly vague instructions
- Missing content types that should have tag formats
6. **Check proxy tools**: Read `server/claude-proxy.ts` and verify tool definitions match what the prompt claims.
7. **Report**: Create a structured summary with:
- Content type coverage matrix (tag/extraction/grid/detail/prompt)
- Identified gaps and inconsistencies
- Priority recommendations
+17
View File
@@ -0,0 +1,17 @@
---
name: check
description: Run all quality checks (typecheck, lint, test) and auto-fix errors
allowed-tools: Bash(*), Read, Edit, Glob, Grep, Agent
---
Run all quality checks for the AIUI project and fix any issues found. Execute in order:
1. **TypeScript**: Run `pnpm typecheck`. If errors found, read the failing files and fix the type errors.
2. **Lint**: Run `pnpm lint`. If fixable errors, run `pnpm lint --fix` first, then fix remaining manually.
3. **Tests**: Run `pnpm --filter @aiui/app test -- --run`. For each failure:
- Read the test file and the source file it tests
- Determine if the test is wrong (outdated assertion) or the source has a bug
- Fix whichever is incorrect
4. Report a summary: pass/fail counts, what was fixed.
Important: Do NOT change test expectations just to make them pass — understand WHY they fail first.
+32
View File
@@ -0,0 +1,32 @@
---
name: deploy
description: Build and prepare AIUI for deployment to Archy node
allowed-tools: Bash(*), Read, Edit, Glob, Grep
---
Build AIUI for production deployment. Steps:
1. **Pre-flight checks**:
- `pnpm typecheck` — must pass
- `pnpm lint` — must pass
- `pnpm --filter @aiui/app test -- --run` — report failures but continue
2. **Build**:
- `pnpm build`
- Verify `packages/app/dist/` exists and contains `index.html`
3. **Bundle analysis**:
- Report total dist size and gzip estimate
- List the 5 largest chunks
- Check against 250KB gzipped budget (warn if over)
4. **Verify nginx config**:
- Read `packages/app/server/nginx-archy.conf`
- Verify SPA routing (`try_files $uri $uri/ /aiui/index.html`)
- Verify proxy paths for Claude API
5. **Container build** (if Dockerfile exists):
- `podman build -t aiui:latest packages/app/`
- Report image size
6. **Report**: Build status, bundle size, any warnings.
+33
View File
@@ -0,0 +1,33 @@
---
name: fix-tab
description: Diagnose and fix a broken content panel tab (extraction, routing, rendering)
allowed-tools: Bash(*), Read, Edit, Glob, Grep, Agent
---
Diagnose and fix a broken content panel tab. The user will specify which tab (e.g., "app", "code", "image", "nostr").
## Diagnostic pipeline — check each layer:
1. **System prompt**: Does `useAI.ts` SYSTEM_PROMPT tell the AI to use the tag format for this content type? If not, add instructions.
2. **Tag regex**: Does `contentExtraction.ts` have a regex for this content type's tags? If not, add one.
3. **Extraction function**: Does `extractAll{Type}s()` or `extract{Type}s()` exist and work correctly? Test with sample input.
4. **Query classifier**: Do `is{Type}Query()` and/or `is{Type}LikeResponse()` exist in `contentFiltering.ts`?
5. **Tab filtering**: Is the tab included in `filterTabsByContext()` and `preferredFirstTab()`? Check the boolean flag is wired.
6. **useContentPanel.ts**: Is the extraction called in `updatePanelFromText()`? Is the panel ref populated? Is the tab added to `availableTabs`?
7. **ContentPanel.vue**: Is there a grid render block for `activeTab === '{tab}'`? Are imports present? Is the tab in TAB_LABELS?
8. **ContentGridView.vue**: Same checks for the wide desktop view.
9. **ChatPage.vue**: Are the panel data props passed to ContentGridView?
10. **Grid component**: Does the grid component exist and render correctly?
11. **Detail component**: Does the detail component exist?
Fix each broken layer. Run `pnpm typecheck` after all fixes.
+32
View File
@@ -0,0 +1,32 @@
---
name: mock-archy
description: Enable/configure mock Archy data for standalone dev testing
allowed-tools: Bash(*), Read, Edit, Glob, Grep
---
Set up or modify mock Archy data for testing the Archipelago integration without a real Archy host.
## How it works
Mock data is in `packages/app/src/mocks/archy.ts`. When enabled, `useArchy.ts` loads this data instead of waiting for the Archy bridge.
## Enable mock mode
Two ways:
1. Add `VITE_MOCK_ARCHY=true` to `.env.local`
2. Add `?mockArchy` to the URL: `http://localhost:5173/?mockArchy`
## Customization
The user may ask to:
- Add/remove mock apps from the installed list
- Change wallet balance or channel count
- Add/modify files in the mock file list
- Change system info or network status
- Test specific scenarios (e.g., "node is syncing", "wallet offline", "no files")
Edit `packages/app/src/mocks/archy.ts` accordingly.
## Verify
After changes, check that `buildArchyContext()` in `useArchy.ts` produces the expected system prompt section by reading the function and tracing the mock data through it.
+27
View File
@@ -0,0 +1,27 @@
---
name: new-detail
description: Generate a detail view component following AIUI glass-morphism patterns
allowed-tools: Read, Write, Edit, Glob, Grep
---
Create a new detail view component at `packages/app/src/components/content/{Name}Detail.vue`.
## Requirements
1. **Read a reference**: Read `BookDetail.vue` or `PlaceDetail.vue` as a template.
2. **Follow conventions**:
- `<script setup lang="ts">` with single item prop
- Back button at top (emits 'back' event)
- Hero image/banner area with gradient overlay and fallback
- Title, subtitle, and metadata section
- Description/long text body with proper typography
- Action buttons (external links, share, etc.) with glass-button styling
- Dark/light mode via `useTheme()`
- Smooth scroll, overflow-y-auto
3. **Props**: Accept single item of the content type
4. **Emits**: `back` event for navigation
5. **Responsive**: Full height, works in sidebar and mobile overlay
The user will specify the content type and which fields to display.
+27
View File
@@ -0,0 +1,27 @@
---
name: new-grid
description: Generate a content grid component following AIUI glass-morphism patterns
allowed-tools: Read, Write, Edit, Glob, Grep
---
Create a new content grid component at `packages/app/src/components/content/{Name}Grid.vue`.
## Requirements
1. **Read a reference**: Read `BookGrid.vue` or `PlaceGrid.vue` as a template — they show the standard pattern.
2. **Follow conventions**:
- `<script setup lang="ts">` with props and emits
- Glass morphism styling (bg-white/5, rounded-xl, hover:bg-white/10)
- Dark/light mode support via `useTheme()`
- Search input at top (if the content type has enough items)
- Grid of cards with image fallback, title, subtitle, metadata
- Touch targets min 44x44px
- Empty state message when no items match
- Custom scrollbar class
3. **Props**: Accept array of items + title string
4. **Emits**: `select-{type}` event when a card is clicked
5. **Responsive**: Works on mobile (full width) and desktop (sidebar width)
The user will specify the content type and its fields.
+26
View File
@@ -0,0 +1,26 @@
---
name: test-prompts
description: Test AI prompt quality by simulating queries and checking extraction results
allowed-tools: Bash(*), Read, Edit, Glob, Grep, Agent
---
Test the AIUI AI prompt and content extraction pipeline end-to-end. This skill does NOT call the actual AI — it uses the seed prompts and extraction functions directly.
## Steps
1. **Read seed prompts**: Read `packages/app/src/__tests__/fixtures/seedPrompts.ts` to get all test cases.
2. **Run extraction tests**: For each seed prompt, run the test via `pnpm --filter @aiui/app test -- --run -t "seed"` and report results.
3. **Test edge cases**: Create and test these additional scenarios by calling extraction functions in a test:
- Mixed content response (films + songs + books in one response)
- App recommendation response (should trigger app tab)
- News query with web search results
- Place/restaurant recommendations
- Code response with 3+ code blocks
- Nostr-related query
- Empty/minimal response
4. **Verify tab routing**: For each scenario, check that `filterTabsByContext()` returns the expected tabs in the expected order.
5. **Report**: Summary of what works, what's broken, and what's missing. Include specific test cases that fail.
+28
View File
@@ -0,0 +1,28 @@
---
name: trace
description: End-to-end trace of a query through prompt, extraction, tabs, and rendering
allowed-tools: Bash(*), Read, Glob, Grep, Agent
---
Trace how a specific user query flows through the entire AIUI pipeline. The user will provide a sample query (e.g., "best nostr apps", "recommend some films", "bitcoin news").
## Trace each stage:
1. **Query classifiers**: Run the query through each classifier in `contentFiltering.ts`:
- `isNewsQuery()`, `isMusicQuery()`, `isBookQuery()`, `isTVQuery()`, `isImageQuery()`, `isPlaceQuery()`, `isRecipeQuery()`, `isCodeQuery()`, `isNostrQuery()`, `isAppQuery()`, `isWebsitesQuery()`
- Report which ones return true
2. **Preferred tab**: What does `preferredFirstTab()` return for this query?
3. **System prompt**: What would `buildSystemPrompt()` include? Read `useAI.ts` and trace all dynamic sections.
4. **Expected AI response**: Based on the system prompt instructions, what tags would the AI likely use? Construct a realistic sample response.
5. **Extraction**: Run the sample response through each extraction function and report what gets found:
- `extractAllFilms()`, `extractAllSongs()`, `extractAllPodcasts()`, `extractAllBooks()`, `extractAllTVSeries()`, `extractAllImages()`, `extractAllPlaces()`, `extractApps()`, `extractCodeBlocks()`, `extractRecipes()`
6. **Tab filtering**: What tabs would `filterTabsByContext()` return? In what order?
7. **Rendering**: Which grid component would render? Trace through ContentPanel.vue and/or ContentGridView.vue.
8. **Report**: Complete flow diagram showing: Query -> Classifiers -> Prompt -> Expected Response -> Extraction -> Tabs -> Grid
@@ -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 sessiondo 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', () => {