feat(app): content detection overhaul, apps tab, chat UX, web search

- Overhaul content detection: expand all query/response classifiers with
  broader AI response patterns (news, music, books, TV, places, websites)
- Add Nostr detection (isNostrQuery, isNostrLikeResponse) and App detection
  (isAppQuery, isAppLikeResponse) classifiers
- Add bare domain extraction from AI text (e.g. "check out damus.io")
- Add Apps tab with curated database of ~30 Nostr + Bitcoin ecosystem apps
  (clients, wallets, privacy tools, node software, dev tools)
- Create AppsGrid + AppDetail components with search, filtering, how-to
- Wire app extraction and Nostr detection into useContentPanel
- Add PromptIndex badges for Apps and Nostr tabs
- Chat UX: dedicated history button, settings modal (memory + advanced),
  fix collapsed chat v-if/v-else chain bug
- Web search: add Brave Search API as primary backend, expand SearXNG pool
- iOS HIG: comprehensive mobile UX rules in cursor rules + CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 05:03:03 +00:00
co-authored by Claude Opus 4.6
parent 5d4367eaff
commit 84ccdc7048
36 changed files with 2295 additions and 321 deletions
-1
View File
@@ -45,7 +45,6 @@ Font weights: `font-normal` (body), `font-medium` (emphasis), `font-semibold` (h
### Buttons (exact Archy values)
- `.glass-button` — 48px height, `bg: rgba(0,0,0,0.6)`, `blur(18px)`, border `rgba(255,255,255,0.18)`, `color: rgba(255,255,255,0.9)`
- `.glass-button-sm` — compact variant (auto height, `py-1.5 px-3`)
- `.gradient-button` — primary action: `linear-gradient(135deg, rgba(255,255,255,0.15) 0%, rgba(0,0,0,0.8) 100%)`, border `rgba(255,255,255,0.2)`, intensifies on hover
### Icon / Ghost buttons (Archy pattern)
```html
-1
View File
@@ -26,7 +26,6 @@ All share: `border: 1px solid rgba(255,255,255,0.18)`, `box-shadow: 0 8px 24px r
|-------|---------|---------|
| `.glass-button` | Default | 48px height, `rgba(0,0,0,0.6)`, blur 18px |
| `.glass-button-sm` | Compact | Auto height, smaller padding |
| `.gradient-button` | Primary action | Gradient bg, intensifies on hover |
| Ghost | Icon/text actions | `p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10` |
### Inset Highlight
+140 -40
View File
@@ -1,69 +1,169 @@
---
description: Mobile UX patterns - touch targets, gestures, safe areas, viewport
description: Mobile UX patterns informed by Apple iOS HIG — touch targets, typography, spacing, navigation, animations
globs: "**/*.vue,**/*.css"
alwaysApply: false
---
# Mobile UX
# Mobile UX (iOS HIG-Informed)
## Philosophy
Design for mobile first, enhance for desktop. Mobile constraints force focus on essential features.
Design for mobile first, enhance for desktop. Follow Apple iOS Human Interface Guidelines for sizing, spacing, and interaction patterns. Adapt native iOS conventions to our glass morphism dark theme.
## Typography (iOS Dynamic Type Mapped to CSS)
| iOS Text Style | Default Size | CSS Equivalent | AIUI Usage |
|---|---|---|---|
| Large Title | 34pt | `text-[34px]` / `text-3xl` | Page titles (rare) |
| Title 1 | 28pt | `text-[28px]` / `text-2xl` | Section headers |
| Title 2 | 22pt | `text-[22px]` / `text-xl` | Sub-section headers |
| Title 3 | 20pt | `text-[20px]` / `text-lg` | Card titles |
| Headline | 17pt semibold | `text-[17px] font-semibold` | Emphasis labels |
| Body | 17pt | `text-[17px]` / `text-base` | Primary content |
| Callout | 16pt | `text-[16px]` | Secondary content |
| Subheadline | 15pt | `text-[15px]` | Metadata |
| Footnote | 13pt | `text-[13px]` / `text-xs` | Timestamps, captions |
| Caption 1 | 12pt | `text-[12px]` | Badges, small labels |
| Caption 2 | 11pt | `text-[11px]` | Smallest text (tab labels) |
### Key rules
- **Minimum text size**: 11px (Caption 2) — never go smaller
- **Body text on mobile**: 17px (not 14px/16px) for comfortable reading
- Use `text-sm` (14px) sparingly — only for dense UI, not primary reading content
- Chat messages should use at least 15-16px on mobile
- Metadata/timestamps: 11-13px is acceptable
## Touch Targets
- Minimum: 44x44px (Apple HIG)
- Optimal: 48x48px (Material Design)
- Minimum gap between targets: 8px
- Icon buttons: use padding to reach target size (`p-3` for 20px icon = 44px total)
## Thumb-Friendly Zones
| Rule | Value | Tailwind |
|---|---|---|
| Minimum tap target | **44 × 44px** | `min-w-[44px] min-h-[44px]` |
| Minimum gap between targets | **8px** | `gap-2` |
| Comfortable button height | 44-50px | `h-11` to `h-[50px]` |
| iOS nav bar button | 44px | `h-11` |
### Key rules
- The 44px minimum applies to the **tappable area**, not the visual size
- A 24px icon can have a 44px tap target via padding: `p-2.5` on a 24px icon
- Our `w-9 h-9` (36px) header buttons are below 44px — compensate with generous spacing or padding hit areas
- Text buttons must extend touch target beyond text bounds
## Spacing & Layout
| Element | iOS Value | CSS |
|---|---|---|
| Side margins (iPhone) | 16px | `px-4` |
| Nav bar height | 44px | `h-11` |
| Tab bar height | 49px (+34px safe area) | `h-[49px]` + `pb-[env(safe-area-inset-bottom)]` |
| Bottom safe area (notch) | 34px | `env(safe-area-inset-bottom)` |
| Search bar | 36px field + 8px padding | `h-9` + `py-1` |
| Standard content inset | 16px horizontal | `px-4` |
### Safe area insets
```css
/* Always use for full-screen layouts */
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
height: 100dvh; /* Dynamic viewport height — avoids iOS Safari toolbar */
```
Top 20%: Hard to reach — header, info
Middle 60%: Easy reach — main content
Bottom 20%: Natural thumb zone — primary actions
## Navigation Patterns
### iOS-native patterns to follow
- **Primary navigation**: Bottom tab bar (persists across screens)
- **Secondary navigation**: Top nav bar with back button (left) and actions (right)
- **Modals**: Sheet sliding up from bottom (half-screen or full)
- **Context menus**: Long-press or action sheets from bottom
### Primary action placement
```
Top 20%: Navigation, info, secondary actions
Middle 60%: Main content (scrollable)
Bottom 20%: Primary actions (thumb zone) — send, approve, play
```
### Sheets & modals on mobile
- Use bottom sheets with three detents: small (~25%), medium (~50%), large (full)
- Always provide a close button — don't rely solely on swipe-to-dismiss
- Content panels: full-screen overlay or bottom sheet, never side-by-side
## Form Inputs
| Rule | Value | Why |
|---|---|---|
| **Minimum input font** | **16px** | Prevents iOS Safari auto-zoom on focus |
| Minimum field height | 44px | Matches tap target |
| Use `inputmode` | `numeric`, `email`, `tel`, `url`, `search` | Shows appropriate keyboard |
| Use `autocomplete` | Standard attributes | Enables autofill |
| Submit button placement | Bottom of form, thumb zone | Easy to reach |
## Animations & Motion (iOS Spring Model)
### Duration guidelines
| Type | Duration | Tailwind |
|---|---|---|
| Micro-interaction (tap, toggle) | 100-200ms | `duration-150` |
| Standard transition (push/pop) | 250-350ms | `duration-300` |
| Modal presentation (sheet) | 300-400ms | `duration-300` |
| Complex transitions | 400-500ms | `duration-500` |
### iOS-style easing
```css
/* Standard iOS-like transition (ease out / decelerate) */
transition: transform 0.35s cubic-bezier(0.2, 0.9, 0.3, 1.0);
/* Bouncy spring-like (for playful entrances) */
transition: transform 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
/* Quick snap (micro-interactions) */
transition: transform 0.25s cubic-bezier(0.0, 0.0, 0.2, 1.0);
```
### Motion rules
- Entrances: ease-out (decelerate)
- Exits: ease-in (accelerate)
- Only animate `transform` and `opacity`
- **Always** respect `prefers-reduced-motion`:
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
Place primary actions (send, approve, play) in the bottom zone. Use FABs at bottom-center on mobile, bottom-right on desktop.
## Gestures
- Swipe left/right: gallery navigation, dismiss
- Swipe left/right: gallery nav, dismiss
- Swipe down: close overlay/bottom sheet, pull-to-refresh
- Long press: context menu, selection
- Pinch: zoom on images
- Minimum swipe distance: 50px before triggering
## Viewport & Safe Areas
```css
/* Dynamic viewport height (avoids iOS address bar issue) */
height: 100dvh;
/* Safe area insets for notched devices */
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
```
## Scroll Behavior
- Lock body scroll when modal/drawer is open
- `overscroll-behavior: contain` on modal content
- `touch-action: manipulation` to prevent zoom on double-tap
- `-webkit-overflow-scrolling: touch` for smooth iOS scroll
## Form Inputs
- Font size minimum 16px on inputs (prevents iOS zoom on focus)
- Use appropriate `inputmode` (numeric, email, tel, url)
- Use `autocomplete` attributes
- Auto-focus next input on completion (OTP pattern)
## Iconography
| Context | Size | Style |
|---|---|---|
| Tab bar | 25px | Filled/solid |
| Nav bar / toolbar | 22px | Outlined, 1.5px stroke |
| Inline with text | Match font size | Outlined |
| Standalone | 28-33px | Filled or outlined |
## Content Panel on Mobile
- No side-by-side layout (chat + panel)
- Panel opens as full-screen overlay or bottom sheet
- Bottom sheet: drag handle at top, swipe down to dismiss
- Back gesture or button returns to chat
- Maintain scroll position when returning
## Orientation
Support both portrait and landscape. Adjust layout with:
```css
@media (orientation: landscape) { /* landscape overrides */ }
```
## AIUI Custom Overrides (Keep These)
These deviate from stock iOS but are intentional for our design language:
- **Dark-only theme**: No light mode. Background `#0a0a0a`, not iOS system colors
- **Glass morphism**: Translucent surfaces with backdrop-blur instead of iOS solid materials
- **Accent color**: Bitcoin orange `#F7931A` instead of iOS systemBlue
- **Text opacity scale**: Our `/25` → `/96` scale instead of iOS label hierarchy
- **No separator borders**: We use spacing and glass layering instead
- **Custom animations**: `animate-fade-up`, `animate-scale-in` per our design system
- **Header buttons**: Currently 36px (`w-9 h-9`), acceptable with generous spacing
## Performance on Mobile
- Test on real devices, not just emulators
+12 -7
View File
@@ -115,7 +115,6 @@ This project uses a glass morphism design language. Key utility classes:
| `.glass-card` | Card variant: `rgba(0,0,0,0.65)`, `border-radius: 1rem` |
| `.glass-button` | Button: 48px height, `rgba(0,0,0,0.6)`, `blur(18px)` |
| `.glass-button-sm` | Compact button variant |
| `.gradient-button` | Primary action gradient button |
| `.gradient-card` | Gradient background card |
### Spacing
@@ -241,13 +240,19 @@ WCAG AA minimum compliance:
- Core Web Vitals: LCP < 2.5s, FID < 100ms, CLS < 0.1
- Preconnect to API hosts, debounce inputs (100ms)
## Mobile UX
## Mobile UX (iOS HIG-Informed)
- Primary actions in bottom thumb zone
- Touch targets: min 44x44px, 8px gap between targets
- Viewport: `height: 100dvh` with safe area insets
- Form inputs: min 16px font (prevents iOS zoom), appropriate `inputmode`
- Content panels: full-screen overlay or bottom sheet on mobile
Follows Apple iOS Human Interface Guidelines. See `.cursor/rules/15-mobile-ux.mdc` for full reference.
- **Typography**: Body 17px, Footnote 13px, Caption 11px minimum — never smaller than 11px
- **Touch targets**: min 44×44px tappable area, 8px gap between targets
- **Side margins**: 16px (`px-4`)
- **Primary actions**: Bottom thumb zone
- **Viewport**: `height: 100dvh` with `env(safe-area-inset-*)` for notched devices
- **Form inputs**: min 16px font (prevents iOS zoom), appropriate `inputmode`
- **Content panels**: Full-screen overlay or bottom sheet on mobile, never side-by-side
- **Transitions**: 150ms micro, 300ms standard, 400ms modal — ease-out entrances, ease-in exits
- **Sheets**: Bottom sheets with close button, don't rely solely on swipe-to-dismiss
- Support both portrait and landscape
## Environment & Dev Mode
+1 -1
View File
@@ -3,7 +3,7 @@
## Context & Philosophy
This plan continues from M0M7 (all complete). Every item below must honour the core philosophy:
- **Glass morphism only** — `glass`, `glass-card`, `glass-button`, `gradient-button`. No light mode, no gray-900 hacks.
- **Glass morphism only** — `glass`, `glass-card`, `glass-button`. No light mode, no gray-900 hacks.
- **Open source / MIT/Apache-2.0** — no proprietary dependencies
- **Decentralised-first** — no vendor lock-in, pluggable everything
- **Bitcoin only** — sats, Lightning, Cashu, Fedimint. AIUI is never a wallet — always deep-link
+83 -25
View File
@@ -2,32 +2,62 @@
# Headless loop script for overnight Claude Code automation.
# Set CLAUDE_AUTONOMOUS=1 for Ralph Wiggum (Stop hook blocks until plan is complete).
# Rate-limit aware: detects limits, sleeps until reset, and retries automatically.
set -eu
set -u
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
PROMPT_FILE="${PROMPT_FILE:-$PROJECT_DIR/loop/prompt.md}"
LOG_FILE="${LOG_FILE:-$PROJECT_DIR/loop/loop.log}"
ITERATION_COUNT="${ITERATION_COUNT:-1}"
ITERATION_DELAY="${ITERATION_DELAY:-600}"
ITERATION_COUNT="${ITERATION_COUNT:-10}"
ITERATION_DELAY="${ITERATION_DELAY:-30}"
CLAUDE_BIN="${CLAUDE_BIN:-claude}"
RATE_LIMIT_WAIT="${RATE_LIMIT_WAIT:-3600}" # Default: wait 1 hour on rate limit
MAX_RATE_LIMIT_RETRIES="${MAX_RATE_LIMIT_RETRIES:-5}" # Max retries before giving up
CLAUDE_EXIT=0
cd "$PROJECT_DIR"
# Human-readable log with visual separators
log() {
echo "$1" | tee -a "$LOG_FILE"
}
banner() {
log ""
log "════════════════════════════════════════════════════════════════"
log " $1"
log " $(date '+%Y-%m-%d %H:%M:%S')"
log "════════════════════════════════════════════════════════════════"
log ""
}
section() {
log ""
log "────────────────────────────────────────"
log " $1"
log "────────────────────────────────────────"
log ""
}
# Check if plan has remaining tasks
plan_has_tasks() {
grep -q '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null
}
# Detect rate limit from Claude output
# Show remaining task count
remaining_tasks() {
grep -c '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null || echo "0"
}
# Show next task
next_task() {
grep -m1 '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null | sed 's/^- \[ \] //' || echo "(none)"
}
# Detect rate limit from Claude output (only when Claude exited non-zero)
check_rate_limit() {
# Check last 50 lines of log for rate limit indicators
tail -50 "$LOG_FILE" 2>/dev/null | grep -qi \
[ "${CLAUDE_EXIT:-0}" -eq 0 ] && return 1
# Check last 50 lines for rate limit indicators, excluding our own log lines
tail -50 "$LOG_FILE" 2>/dev/null | grep -v "^Rate limit detected" | grep -v "^Sleeping" | grep -v "^═" | grep -v "^─" | grep -qi \
-e "rate.limit" \
-e "too.many.requests" \
-e "429" \
@@ -36,26 +66,46 @@ check_rate_limit() {
-e "limit.reached" 2>/dev/null
}
log "=== Overnight loop started $(date '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || date) ==="
log " PROMPT_FILE=$PROMPT_FILE"
log " CLAUDE_AUTONOMOUS=${CLAUDE_AUTONOMOUS:-0}"
log " ITERATION_COUNT=$ITERATION_COUNT"
log " RATE_LIMIT_WAIT=${RATE_LIMIT_WAIT}s"
banner "OVERNIGHT AUTOMATION STARTED"
log " Project: $PROJECT_DIR"
log " Prompt: $PROMPT_FILE"
log " Autonomous: ${CLAUDE_AUTONOMOUS:-0}"
log " Iterations: $ITERATION_COUNT (${ITERATION_DELAY}s between each)"
log " Rate limit: wait ${RATE_LIMIT_WAIT}s, retry up to ${MAX_RATE_LIMIT_RETRIES}x"
log " Tasks left: $(remaining_tasks)"
log " Next task: $(next_task)"
log ""
i=1
rate_limit_retries=0
while [ "$i" -le "$ITERATION_COUNT" ]; do
log "--- Iteration $i/$ITERATION_COUNT @ $(date '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || date) ---"
# Check if there are tasks remaining before starting
if ! plan_has_tasks; then
banner "ALL TASKS COMPLETE"
log " No remaining tasks in plan.md. Stopping."
break
fi
section "ITERATION $i/$ITERATION_COUNT"
log " Tasks remaining: $(remaining_tasks)"
log " Next task: $(next_task)"
log ""
export CLAUDE_PROJECT_DIR="$PROJECT_DIR"
export CLAUDE_AUTONOMOUS="${CLAUDE_AUTONOMOUS:-1}"
# Run Claude with autonomous permissions; prompt from file
if [ -f "$PROMPT_FILE" ]; then
"$CLAUDE_BIN" -p --dangerously-skip-permissions --output-format=stream-json \
< "$PROMPT_FILE" 2>&1 | tee -a "$LOG_FILE" || true
log " Starting Claude session..."
log ""
"$CLAUDE_BIN" -p --dangerously-skip-permissions \
< "$PROMPT_FILE" 2>&1 | tee -a "$LOG_FILE"
CLAUDE_EXIT=$?
log ""
log " Claude exited with code: $CLAUDE_EXIT"
else
log "Error: $PROMPT_FILE not found"
log " ERROR: $PROMPT_FILE not found"
exit 1
fi
@@ -63,8 +113,8 @@ while [ "$i" -le "$ITERATION_COUNT" ]; do
if check_rate_limit; then
rate_limit_retries=$((rate_limit_retries + 1))
if [ "$rate_limit_retries" -ge "$MAX_RATE_LIMIT_RETRIES" ]; then
log "Rate limited $rate_limit_retries times — giving up."
log "Scheduling retry via launchd..."
section "RATE LIMITED — SCHEDULING LAUNCHD RETRY"
log " Hit rate limit $rate_limit_retries times. Creating launchd job to retry later."
# Schedule a retry using launchd for after rate limit resets
PLIST_LABEL="com.aiui.overnight-retry"
@@ -111,32 +161,40 @@ while [ "$i" -le "$ITERATION_COUNT" ]; do
PLIST
launchctl load "$PLIST_PATH" 2>/dev/null || true
log "Scheduled retry at ~${RETRY_TIME} via launchd ($PLIST_PATH)"
log "The plist auto-removes after running."
log " Scheduled retry at ~${RETRY_TIME}"
log " Plist: $PLIST_PATH (auto-removes after running)"
exit 0
fi
log "Rate limit detected (attempt $rate_limit_retries/$MAX_RATE_LIMIT_RETRIES)."
log "Sleeping ${RATE_LIMIT_WAIT}s until $(date -v+${RATE_LIMIT_WAIT}S '+%H:%M:%S' 2>/dev/null || date -d "+${RATE_LIMIT_WAIT} seconds" '+%H:%M:%S')..."
section "RATE LIMITED — WAITING"
log " Attempt $rate_limit_retries/$MAX_RATE_LIMIT_RETRIES"
log " Sleeping ${RATE_LIMIT_WAIT}s until $(date -v+${RATE_LIMIT_WAIT}S '+%H:%M:%S' 2>/dev/null || date -d "+${RATE_LIMIT_WAIT} seconds" '+%H:%M:%S')..."
sleep "$RATE_LIMIT_WAIT"
# Check if plan still has tasks before retrying
if ! plan_has_tasks; then
log "All plan tasks completed during rate limit wait. Done."
banner "ALL TASKS COMPLETE (during rate limit wait)"
break
fi
log "Retrying after rate limit..."
log " Retrying..."
continue # Retry same iteration
fi
# Reset rate limit counter on successful run
rate_limit_retries=0
section "ITERATION $i COMPLETE"
log " Tasks remaining: $(remaining_tasks)"
log " Next task: $(next_task)"
i=$((i + 1))
if [ "$i" -le "$ITERATION_COUNT" ] && [ "$ITERATION_DELAY" -gt 0 ]; then
log "Waiting ${ITERATION_DELAY}s before next iteration..."
log " Pausing ${ITERATION_DELAY}s before next iteration..."
sleep "$ITERATION_DELAY"
fi
done
log "=== Loop complete $(date '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || date) ==="
banner "LOOP FINISHED"
log " Completed $((i - 1)) iterations"
log " Tasks remaining: $(remaining_tasks)"
log ""
+1 -1
View File
@@ -7,7 +7,7 @@ You are executing the AIUI product roadmap autonomously. Read these files first:
## Design System Rules (must follow for every file you touch)
- **Glass morphism only** — use `.glass`, `.glass-card`, `.glass-button`, `.glass-button-sm`, `.gradient-button` from `src/styles/main.css`
- **Glass morphism only** — use `.glass`, `.glass-card`, `.glass-button`, `.glass-button-sm` from `src/styles/main.css`
- **No light-mode conditionals** — never use `isDark ?` pattern. The app is dark-only. Remove any existing light-mode code you encounter.
- **No `bg-gray-*` or `bg-white`** — use `bg-white/5`, `bg-white/10`, `bg-black/35` etc.
- **Text opacity scale** — `text-white/25``/40``/60``/70``/80``/90``/96``text-white`
+6 -1
View File
@@ -23,6 +23,11 @@
"pnpm": ">=10.0.0"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
"onlyBuiltDependencies": [
"esbuild"
]
},
"dependencies": {
"pdfjs-dist": "^5.5.207"
}
}
+23 -2
View File
@@ -67,7 +67,7 @@ if (!self.define) {
});
};
}
define(['./workbox-cf23aef7'], (function (workbox) { 'use strict';
define(['./workbox-f97094b3'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
@@ -82,7 +82,7 @@ define(['./workbox-cf23aef7'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.jv7f3n1n5ic"
"revision": "0.0t39d8ip1tk"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
@@ -92,5 +92,26 @@ define(['./workbox-cf23aef7'], (function (workbox) { 'use strict';
workbox.registerRoute(/^https:\/\/openrouter\.ai\/.*/i, new workbox.NetworkOnly(), 'GET');
workbox.registerRoute(/\/api\/web-search\?.*/i, new workbox.NetworkOnly(), 'GET');
workbox.registerRoute(/\/api\/rss-articles\?.*/i, new workbox.NetworkOnly(), 'GET');
workbox.registerRoute(/\/api\/tmdb\/.*/i, new workbox.StaleWhileRevalidate({
"cacheName": "tmdb-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 86400
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/image\.tmdb\.org\/.*/i, new workbox.CacheFirst({
"cacheName": "tmdb-images",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 500,
maxAgeSeconds: 604800
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/upload\.wikimedia\.org\/.*/i, new workbox.CacheFirst({
"cacheName": "wiki-images",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 604800
})]
}), 'GET');
}));
+175 -26
View File
@@ -1,6 +1,6 @@
import { spawn } from 'child_process'
import { createServer } from 'http'
import { readFileSync, existsSync } from 'fs'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
@@ -34,13 +34,132 @@ const PORT = 3141
const CLAUDE_BIN = resolve(process.env.HOME ?? '', '.local/bin/claude')
const APP_URL = process.env.APP_URL ?? 'http://localhost:5173'
/** API key (sk-ant-api03-...) or OAuth token (sk-ant-oat...) from Max subscription */
function getAnthropicCredential(): string | undefined {
// ---------------------------------------------------------------------------
// OAuth credential management — reads from ~/.claude/.credentials.json
// (the same file Claude Code uses) with automatic token refresh
// ---------------------------------------------------------------------------
const OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
const CREDENTIALS_PATH = resolve(process.env.HOME ?? '', '.claude/.credentials.json')
interface OAuthCredentials {
accessToken: string
refreshToken: string
expiresAt: number
scopes?: string[]
subscriptionType?: string
rateLimitTier?: string
}
/** Read Claude Code's OAuth credentials from disk (fresh every call) */
function readClaudeCredentials(): OAuthCredentials | undefined {
try {
if (!existsSync(CREDENTIALS_PATH)) return undefined
const json = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'))
const oauth = json?.claudeAiOauth
if (oauth?.accessToken && oauth?.refreshToken) {
return oauth as OAuthCredentials
}
} catch {
/* ignore */
}
return undefined
}
/** Persist refreshed credentials back to ~/.claude/.credentials.json */
function writeClaudeCredentials(creds: OAuthCredentials): void {
try {
let json: Record<string, unknown> = {}
if (existsSync(CREDENTIALS_PATH)) {
json = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'))
}
json.claudeAiOauth = creds
writeFileSync(CREDENTIALS_PATH, JSON.stringify(json, null, 2) + '\n', 'utf8')
console.log('[proxy] Refreshed OAuth token written to', CREDENTIALS_PATH)
} catch (err) {
console.error('[proxy] Failed to write refreshed credentials:', err)
}
}
/** Refresh an expired OAuth token using the refresh token */
async function refreshOAuthToken(refreshToken: string): Promise<OAuthCredentials | undefined> {
try {
console.log('[proxy] OAuth token expired, refreshing...')
const res = await fetch(OAUTH_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: OAUTH_CLIENT_ID,
}),
signal: AbortSignal.timeout(15000),
})
if (!res.ok) {
const body = await res.text().catch(() => '')
console.error(`[proxy] OAuth refresh failed ${res.status}: ${body.slice(0, 200)}`)
return undefined
}
const data = (await res.json()) as {
access_token?: string
refresh_token?: string
expires_in?: number
}
if (!data.access_token) {
console.error('[proxy] OAuth refresh response missing access_token')
return undefined
}
const creds: OAuthCredentials = {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? refreshToken,
expiresAt: Date.now() + (data.expires_in ?? 3600) * 1000,
}
writeClaudeCredentials(creds)
console.log('[proxy] OAuth token refreshed successfully (expires in', data.expires_in ?? 3600, 's)')
return creds
} catch (err) {
console.error('[proxy] OAuth refresh error:', err instanceof Error ? err.message : err)
return undefined
}
}
/**
* Get a valid Anthropic credential, checking multiple sources:
* 1. Env vars (ANTHROPIC_API_KEY, ANTHROPIC_TOKEN, etc.)
* 2. ~/.claude/.credentials.json (Claude Code OAuth — auto-refreshed)
* 3. ~/.claude/settings.json env section
*
* Re-reads from disk on every call so we always pick up fresh tokens.
*/
async function getAnthropicCredential(): Promise<string | undefined> {
// 1. Env vars (highest priority — explicit override)
const fromEnv = process.env.ANTHROPIC_API_KEY
?? process.env.VITE_ANTHROPIC_API_KEY
?? process.env.ANTHROPIC_TOKEN
?? process.env.VITE_ANTHROPIC_TOKEN
if (fromEnv) return fromEnv
// 2. Claude Code credentials file (OAuth — primary path for Max users)
const creds = readClaudeCredentials()
if (creds) {
// Check if token is expired (with 60s buffer)
if (creds.expiresAt > Date.now() + 60_000) {
return creds.accessToken
}
// Token expired — try to refresh
const refreshed = await refreshOAuthToken(creds.refreshToken)
if (refreshed) return refreshed.accessToken
// Refresh failed but token might still work briefly — try it anyway
if (creds.expiresAt > Date.now()) return creds.accessToken
}
// 3. Claude settings.json env section (legacy)
const home = process.env.HOME ?? ''
const settingsPath = resolve(home, '.claude/settings.json')
if (home && existsSync(settingsPath)) {
@@ -53,10 +172,10 @@ function getAnthropicCredential(): string | undefined {
}
} catch { /* ignore */ }
}
return undefined
}
const ANTHROPIC_CREDENTIAL = getAnthropicCredential()
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? process.env.VITE_OPENROUTER_API_KEY ?? ''
const isOAuthToken = (s: string) => /^sk-ant-oat/.test(s)
@@ -76,9 +195,9 @@ const SEARCH_WEB_TOOL = {
}
function mapModelToApi(model: string): string {
if (model?.includes('opus')) return 'claude-opus-4-20250514'
if (model?.includes('haiku')) return 'claude-3-5-haiku-20241022'
return 'claude-sonnet-4-20250514'
if (model?.includes('opus')) return 'claude-opus-4-5-20250918'
if (model?.includes('haiku')) return 'claude-haiku-4-5-20251001'
return 'claude-sonnet-4-5-20250514'
}
async function runSearchWeb(query: string): Promise<string> {
@@ -101,10 +220,12 @@ async function runSearchWeb(query: string): Promise<string> {
}
async function streamViaAnthropicApi(
credential: string,
model: string,
system: string | undefined,
messages: { role: string; content: string }[],
res: import('http').ServerResponse,
webSearch: boolean,
): Promise<void> {
const apiModel = mapModelToApi(model)
const apiMessages = messages.map((m) => ({
@@ -128,7 +249,7 @@ async function streamViaAnthropicApi(
}
let turnMessages = [...apiMessages]
const maxToolRounds = 5
const maxToolRounds = webSearch ? 5 : 1
let rounds = 0
while (rounds < maxToolRounds) {
@@ -138,18 +259,20 @@ async function streamViaAnthropicApi(
max_tokens: 4096,
system,
messages: turnMessages,
tools: [SEARCH_WEB_TOOL],
}
if (webSearch) {
body.tools = [SEARCH_WEB_TOOL]
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
}
if (isOAuthToken(ANTHROPIC_CREDENTIAL!)) {
headers['Authorization'] = `Bearer ${ANTHROPIC_CREDENTIAL}`
if (isOAuthToken(credential)) {
headers['Authorization'] = `Bearer ${credential}`
headers['anthropic-beta'] = 'oauth-2025-04-20'
} else {
headers['x-api-key'] = ANTHROPIC_CREDENTIAL!
headers['x-api-key'] = credential
}
const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
@@ -296,7 +419,7 @@ const server = createServer((req, res) => {
let body = ''
req.on('data', (chunk) => { body += chunk })
req.on('end', () => {
req.on('end', async () => {
if (req.url === '/v1/openrouter') {
console.log('[proxy] → OpenRouter proxy')
streamOpenRouterProxy(body, res)
@@ -306,9 +429,12 @@ const server = createServer((req, res) => {
const payload = JSON.parse(body)
const { model, messages, system, webSearch } = payload
const useTools = webSearch === true && !!ANTHROPIC_CREDENTIAL
// Get a fresh credential on every request (handles token refresh)
const credential = await getAnthropicCredential()
if (useTools) {
if (credential) {
// Direct Anthropic API path — always preferred (no CLI needed)
console.log(`[proxy] → API ${mapModelToApi(model)}${webSearch ? ' [WebSearch]' : ''}`)
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
@@ -316,13 +442,12 @@ const server = createServer((req, res) => {
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no',
})
streamViaAnthropicApi(model, system, messages ?? [], res)
await streamViaAnthropicApi(credential, model, system, messages ?? [], res, webSearch === true)
return
}
if (webSearch === true && !ANTHROPIC_CREDENTIAL) {
console.log('[proxy] webSearch: using CLI built-in WebSearch + pre-fetched context')
}
// Fallback: Claude CLI (works when logged in from a normal terminal)
console.log('[proxy] No API credential — falling back to Claude CLI')
const modelFlag = model?.includes('opus') ? 'opus'
: model?.includes('haiku') ? 'haiku'
@@ -355,6 +480,11 @@ const server = createServer((req, res) => {
console.log(`[proxy] → claude -p --model ${modelFlag}${webSearch ? ' [WebSearch]' : ''} "${lastUserMsg.slice(0, 60)}..."`)
const procEnv = { ...process.env, NO_COLOR: '1', TERM: 'dumb' }
// Unset Claude Code session vars so the CLI can spawn inside a CC session
delete procEnv.CLAUDECODE
delete procEnv.CLAUDE_CODE
delete procEnv.ANTHROPIC_CLAUDE_CODE
delete procEnv.CLAUDE_CODE_ENTRYPOINT
if (webSearch === true) {
delete procEnv.DISALLOWED_TOOLS
}
@@ -377,6 +507,17 @@ const server = createServer((req, res) => {
let fullOutput = ''
let clientDisconnected = false
// Hard timeout — if CLI produces no output in 30s, return an error
const cliTimeout = setTimeout(() => {
if (fullOutput.length === 0 && !clientDisconnected) {
console.warn('[proxy] CLI timeout (30s no output) — killing process')
proc.kill('SIGTERM')
res.write(`data: ${JSON.stringify({ type: 'error', error: { message: 'Claude CLI timed out. Check that the claude binary is working: ~/.local/bin/claude -p "hi"' } })}\n\n`)
res.write('data: [DONE]\n\n')
res.end()
}
}, 30000)
proc.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString()
fullOutput += text
@@ -397,6 +538,7 @@ const server = createServer((req, res) => {
})
proc.on('error', (err) => {
clearTimeout(cliTimeout)
console.error('[proxy] spawn error:', err)
if (!clientDisconnected) {
const errData = {
@@ -410,6 +552,7 @@ const server = createServer((req, res) => {
})
proc.on('close', (code, signal) => {
clearTimeout(cliTimeout)
console.log(`[proxy] ← exit code=${code} signal=${signal} output=${fullOutput.length}b`)
if (!clientDisconnected) {
res.write('data: [DONE]\n\n')
@@ -418,6 +561,7 @@ const server = createServer((req, res) => {
})
res.on('close', () => {
clearTimeout(cliTimeout)
clientDisconnected = true
if (proc.exitCode === null && !proc.killed) {
console.log('[proxy] Client disconnected, killing process')
@@ -436,14 +580,19 @@ const server = createServer((req, res) => {
})
})
server.listen(PORT, () => {
server.listen(PORT, async () => {
console.log(`\n Claude proxy → http://localhost:${PORT}`)
console.log(` Binary: ${CLAUDE_BIN}`)
if (ANTHROPIC_CREDENTIAL) {
const mode = isOAuthToken(ANTHROPIC_CREDENTIAL) ? 'OAuth (Max)' : 'API key'
console.log(` Tool use (search_web): enabled (${mode})`)
const credential = await getAnthropicCredential()
if (credential) {
const mode = isOAuthToken(credential) ? 'OAuth (Max)' : 'API key'
console.log(` Auth: ${mode} (token ...${credential.slice(-8)})`)
console.log(` Mode: Direct Anthropic API (no CLI needed)`)
} else {
console.log(` Tool use: add ANTHROPIC_TOKEN (Max) or ANTHROPIC_API_KEY to .env.local`)
console.log(` Auth: none found`)
console.log(` Checked: env vars, ~/.claude/.credentials.json, ~/.claude/settings.json`)
console.log(` Fallback: Claude CLI (${CLAUDE_BIN})`)
}
console.log(` OpenRouter proxy: ${OPENROUTER_API_KEY ? 'enabled' : 'add OPENROUTER_API_KEY to .env.local'}\n`)
console.log(` OpenRouter proxy: ${OPENROUTER_API_KEY ? 'enabled' : 'add OPENROUTER_API_KEY to .env.local'}`)
console.log()
})
+31 -62
View File
@@ -9,7 +9,7 @@
class="w-8 h-8 rounded-xl path-glass-icon flex items-center justify-center shrink-0 transition-colors cursor-pointer text-[#fafafa] hover:text-white"
:title="`AI model: ${modelDisplayName}`"
aria-label="Select AI model"
@click="showModelPicker = !showModelPicker; showChatList = false"
@click="showModelPicker = !showModelPicker"
>
<span class="text-base"></span>
</button>
@@ -28,12 +28,26 @@
</svg>
</button>
<button
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
:class="chatStore.showHistory
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="chatStore.showHistory ? 'Back to chat' : 'Chat history'"
aria-label="Toggle chat history"
@click="chatStore.toggleHistory()"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
:class="chatStore.chatCollapsed
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="chatStore.chatCollapsed ? 'Expand chat' : 'Collapse to prompts'"
:title="chatStore.chatCollapsed ? 'Expand chat' : 'Prompt index'"
aria-label="Toggle prompt index"
@click="chatStore.toggleChatCollapse()"
>
@@ -59,6 +73,18 @@
</svg>
</button>
<button
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors text-white/70 hover:text-white"
aria-label="Settings"
title="Settings"
@click="$emit('openSettings')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<button
ref="menuTriggerRef"
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors text-white/70 hover:text-white"
@@ -93,44 +119,14 @@
</div>
</div>
<button
ref="chatListTriggerRef"
class="w-full text-left pt-3 pb-1 min-w-0"
:class="conversationList.length > 0 ? 'cursor-pointer' : ''"
@click="conversationList.length > 0 && (showChatList = !showChatList)"
>
<div class="w-full text-left pt-3 pb-1 min-w-0">
<h2 class="text-sm font-semibold truncate text-white/96">{{ title }}</h2>
<div class="flex items-center gap-1.5 mt-0.5">
<p class="text-[10px] truncate font-mono text-white/40">{{ conversationId }}</p>
<span class="text-[10px] text-white/20">·</span>
<span class="text-[10px] truncate text-white/50">{{ modelDisplayName }}</span>
</div>
</button>
<Teleport to="body">
<div v-if="showChatList && conversationList.length > 0" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showChatList = false" />
<Transition name="picker">
<div
v-if="showChatList && conversationList.length > 0"
class="fixed z-[9999] path-glass-card p-3 max-h-48 overflow-y-auto animate-fade-up-fast shadow-2xl min-w-[200px]"
:style="chatListDropdownStyle"
@click.stop
>
<p class="text-[10px] font-semibold uppercase tracking-wider mb-2 px-1 text-white/40">Saved chats</p>
<button
v-for="c in conversationList"
:key="c.id"
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all"
:class="c.id === activeConversationId
? 'nav-tab-active'
: 'text-white/60 hover:text-white hover:bg-white/10'"
@click="selectChat(c.id)"
>
{{ c.title || 'Untitled' }}
</button>
</div>
</Transition>
</Teleport>
</div>
<Teleport to="body">
<div v-if="showModelPicker" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showModelPicker = false" />
@@ -278,6 +274,7 @@ defineEmits<{
switchSide: []
newChat: []
close: []
openSettings: []
}>()
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
@@ -301,30 +298,13 @@ function getModelCaps(modelId: string) {
const chatStore = useChatStore()
const webSearchEnabled = computed(() => chatStore.webSearchEnabled)
const showModelPicker = ref(false)
const showChatList = ref(false)
const showMenu = ref(false)
const menuTriggerRef = ref<HTMLElement | null>(null)
const menuDropdownStyle = ref<Record<string, string>>({})
const headerRef = ref<HTMLElement | null>(null)
const chatListTriggerRef = ref<HTMLElement | null>(null)
const modelPickerTriggerRef = ref<HTMLElement | null>(null)
const chatListDropdownStyle = ref<Record<string, string>>({})
const modelPickerDropdownStyle = ref<Record<string, string>>({})
function updateChatListPosition() {
nextTick(() => {
const el = chatListTriggerRef.value
if (el) {
const r = el.getBoundingClientRect()
chatListDropdownStyle.value = {
top: `${r.bottom + 4}px`,
left: `${r.left}px`,
width: `${Math.max(r.width, 200)}px`,
}
}
})
}
function updateModelPickerPosition() {
nextTick(() => {
const el = modelPickerTriggerRef.value
@@ -353,21 +333,10 @@ function updateMenuPosition() {
})
}
watch(showChatList, (v) => { if (v) updateChatListPosition() })
watch(showModelPicker, (v) => { if (v) updateModelPickerPosition() })
watch(showMenu, (v) => { if (v) updateMenuPosition() })
const conversationList = computed(() => chatStore.conversationList)
const activeConversationId = computed(() => chatStore.activeConversationId)
function selectChat(id: string) {
chatStore.setActiveConversation(id)
showChatList.value = false
}
watch(() => chatStore.conversationList.length, () => {
showChatList.value = false
})
const modelDisplayName = computed(() => {
for (const p of availableProviders.value) {
@@ -0,0 +1,70 @@
<template>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide p-3 space-y-1">
<button
class="w-full text-left px-3 py-2.5 rounded-xl transition-all duration-150 hover:bg-white/5 flex items-center gap-2 text-white/70 mb-2"
@click="$emit('newChat')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span class="text-sm font-medium">New Chat</span>
</button>
<div v-if="conversations.length === 0" class="flex items-center justify-center h-32">
<p class="text-xs text-white/30">No conversations yet</p>
</div>
<button
v-for="conv in conversations"
:key="conv.id"
class="w-full text-left px-3 py-2.5 rounded-xl transition-all duration-150"
:class="conv.id === activeId ? 'nav-tab-active' : 'hover:bg-white/5'"
@click="selectConversation(conv.id)"
>
<p class="text-sm leading-snug truncate" :class="conv.id === activeId ? 'text-white' : 'text-white/90'">
{{ conv.title || 'Untitled' }}
</p>
<div class="flex items-center gap-1.5 mt-1">
<span class="text-[10px] text-white/30">
{{ formatTime(conv.updatedAt) }}
</span>
<span class="text-[10px] text-white/20">&middot;</span>
<span class="text-[10px] text-white/30">
{{ conv.messages.length }} msg{{ conv.messages.length !== 1 ? 's' : '' }}
</span>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useChatStore } from '@/stores/chat'
const emit = defineEmits<{
select: [id: string]
newChat: []
}>()
const chatStore = useChatStore()
const conversations = computed(() => chatStore.conversationList)
const activeId = computed(() => chatStore.activeConversationId)
function selectConversation(id: string) {
emit('select', id)
}
function formatTime(ts: number): string {
const now = Date.now()
const diff = now - ts
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'Just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return new Date(ts).toLocaleDateString([], { month: 'short', day: 'numeric' })
}
</script>
@@ -8,6 +8,7 @@
@switch-side="$emit('switchSide')"
@new-chat="handleNewChat"
@close="$emit('close')"
@open-settings="showSettings = true"
/>
<BranchSwitcher />
@@ -21,15 +22,25 @@
<ContextBar :messages="messages" :active-model="activeModel" />
<PersonaSelector />
<MemoryPanel />
<AdvancedSettings />
<SettingsModal v-model:open="showSettings" />
<!-- History: full conversation list -->
<ChatHistory
v-if="showHistory"
@select="handleHistorySelect"
@new-chat="handleNewChat"
/>
<!-- Collapsed: prompt index -->
<PromptIndex
v-if="chatCollapsed"
:messages="messages"
@select="handlePromptSelect"
/>
<template v-else-if="chatCollapsed">
<PromptIndex
:messages="messages"
@select="handlePromptSelect"
/>
<div v-if="isStreaming" class="px-4 pb-3">
<StreamingDots />
</div>
</template>
<!-- Expanded: full message list (virtualized) -->
<div
@@ -117,13 +128,13 @@ import ChatMessage from './ChatMessage.vue'
import ChatInput from './ChatInput.vue'
import StreamingDots from './StreamingDots.vue'
import PromptIndex from './PromptIndex.vue'
import ChatHistory from './ChatHistory.vue'
import BranchSwitcher from './BranchSwitcher.vue'
import ChatSearch from './ChatSearch.vue'
import ContextBar from './ContextBar.vue'
import ComparisonView from './ComparisonView.vue'
import PersonaSelector from './PersonaSelector.vue'
import MemoryPanel from './MemoryPanel.vue'
import AdvancedSettings from './AdvancedSettings.vue'
import SettingsModal from './SettingsModal.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import type { Message, ImageAttachment } from '@aiui/core/types/message'
@@ -155,6 +166,7 @@ const personaStore = usePersonaStore()
const comparison = useComparisonMode()
const messageListRef = ref<HTMLElement | null>(null)
const chatSearchRef = ref<InstanceType<typeof ChatSearch> | null>(null)
const showSettings = ref(false)
// Scroll position memory per conversation
const scrollPositions = new Map<string, number>()
@@ -206,6 +218,7 @@ const virtualizer = useVirtualizer(computed(() => ({
})))
const isStreaming = computed(() => chatStore.isStreaming)
const chatCollapsed = computed(() => chatStore.chatCollapsed)
const showHistory = computed(() => chatStore.showHistory)
const title = computed(
() => chatStore.activeConversation?.title ?? 'New Chat'
@@ -231,6 +244,12 @@ function getTriggeringQuery(msgs: typeof messages.value, idx: number): string {
function handleNewChat() {
chatStore.createConversation('New Chat', personaStore.defaultPersona?.id)
chatStore.showHistory = false
}
function handleHistorySelect(id: string) {
chatStore.setActiveConversation(id)
chatStore.showHistory = false
}
function handleStop() {
@@ -370,6 +389,13 @@ function scrollToBottom() {
})
}
// Scroll to bottom when expanding from collapsed
watch(chatCollapsed, (collapsed, wasCollapsed) => {
if (!collapsed && wasCollapsed) {
scrollToBottom()
}
})
// Save/restore scroll position on conversation switch
watch(
() => chatStore.activeConversationId,
@@ -1,7 +1,7 @@
<template>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide p-3 space-y-1">
<div v-if="promptPairs.length === 0" class="flex items-center justify-center h-full">
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
<p class="text-xs text-white/30">
No prompts yet
</p>
</div>
@@ -13,28 +13,21 @@
:class="[
activeIndex === i
? 'path-glass-bubble-user'
: isDark
? 'hover:bg-white/5'
: 'hover:bg-black/3'
: 'hover:bg-white/5'
]"
@click="selectPrompt(pair, i)"
>
<p class="text-sm leading-snug truncate"
:class="isDark ? 'text-white/90' : 'text-gray-800'">
<p class="text-sm leading-snug truncate text-white/90">
{{ pair.userMsg.content }}
</p>
<div class="flex items-center gap-1.5 mt-1">
<span class="text-[10px]"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
<div class="flex items-center gap-1.5 mt-1 flex-wrap">
<span class="text-[10px] text-white/30">
{{ formatTime(pair.userMsg.timestamp) }}
</span>
<span
v-for="badge in pair.badges"
:key="badge"
class="text-[9px] px-1.5 py-0.5 rounded-md"
:class="isDark
? 'bg-white/8 text-white/40'
: 'bg-black/5 text-gray-500'"
class="text-[9px] px-1.5 py-0.5 rounded-md bg-white/8 text-white/40"
>
{{ badge }}
</span>
@@ -46,7 +39,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Message } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
interface PromptPair {
@@ -63,7 +55,6 @@ const emit = defineEmits<{
select: [userMsg: Message, assistantMsg: Message | null]
}>()
const { isDark } = useTheme()
const { getContextualInlineContent } = useContentPanel()
const activeIndex = ref<number | null>(null)
@@ -96,6 +87,8 @@ const promptPairs = computed<PromptPair[]>(() => {
if (content.magazineSections.length > 0) badges.push('Magazine')
if ((content.newsLinks?.length ?? 0) > 0) badges.push('News')
if ((content.websitesLinks?.length ?? 0) > 0) badges.push('Web')
if ((content.apps?.length ?? 0) > 0) badges.push('Apps')
if (content.hasNostr) badges.push('Nostr')
}
pairs.push({ userMsg, assistantMsg, badges })
@@ -0,0 +1,322 @@
<template>
<Teleport to="body">
<Transition name="settings-modal">
<div
v-if="open"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
@click.self="$emit('update:open', false)"
/>
<div class="glass-card relative w-full max-w-md p-5 space-y-5 animate-scale-in max-h-[85vh] overflow-y-auto scrollbar-hide">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-white/96">Settings</h2>
<button
class="w-7 h-7 flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
aria-label="Close settings"
@click="$emit('update:open', false)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Memory Section -->
<div class="space-y-2">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-white/40">
Memory ({{ memoryStore.items.length }}/20)
</h3>
<div class="space-y-1.5">
<div
v-for="item in memoryStore.items"
:key="item.id"
class="group flex items-start gap-2 rounded-lg bg-white/5 border border-white/5 px-2.5 py-1.5"
>
<div v-if="editingId === item.id" class="flex-1 flex gap-1.5">
<input
v-model="editText"
type="text"
class="flex-1 bg-transparent text-xs text-white/80 outline-none"
@keydown.enter="saveEdit(item.id)"
@keydown.escape="cancelEdit"
/>
<button
class="text-[10px] text-accent/70 hover:text-accent"
@click="saveEdit(item.id)"
>
Save
</button>
</div>
<template v-else>
<p class="flex-1 text-xs text-white/60 leading-relaxed">{{ item.text }}</p>
<div class="shrink-0 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
class="w-5 h-5 flex items-center justify-center rounded text-white/30 hover:text-white/60 hover:bg-white/10 transition-all"
title="Edit"
@click="startEdit(item)"
>
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
</button>
<button
class="w-5 h-5 flex items-center justify-center rounded text-white/30 hover:text-red-400/80 hover:bg-white/10 transition-all"
title="Delete"
@click="memoryStore.deleteItem(item.id)"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
</div>
<div v-if="!memoryStore.isFull" class="flex gap-1.5">
<input
v-model="newMemoryText"
type="text"
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add a memory..."
@keydown.enter="addMemory"
/>
<button
class="px-2.5 py-1.5 rounded-lg text-[11px] bg-accent/15 text-accent/80 hover:bg-accent/25 transition-all"
:disabled="!newMemoryText.trim()"
@click="addMemory"
>
Add
</button>
</div>
<p v-else class="text-[10px] text-white/25">
Maximum 20 memories reached
</p>
</div>
</div>
<!-- Divider -->
<div class="border-t border-white/5" />
<!-- Advanced Section -->
<div class="space-y-3">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-white/40">
Advanced
</h3>
<template v-if="conv">
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-[11px] text-white/40">Temperature</label>
<span class="text-[11px] text-white/50 tabular-nums">{{ temperature.toFixed(2) }}</span>
</div>
<input
v-model.number="temperature"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-[11px] text-white/40">Max Tokens</label>
<span class="text-[11px] text-white/50 tabular-nums">{{ maxTokens }}</span>
</div>
<input
v-model.number="maxTokens"
type="range"
min="256"
max="8192"
step="256"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-[11px] text-white/40">Top P</label>
<span class="text-[11px] text-white/50 tabular-nums">{{ topP.toFixed(2) }}</span>
</div>
<input
v-model.number="topP"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<label class="text-[11px] text-white/40">Stop Sequences</label>
<div v-if="stopSequences.length > 0" class="flex gap-1 flex-wrap mb-1">
<span
v-for="(seq, i) in stopSequences"
:key="i"
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-white/5 border border-white/10 text-[10px] text-white/50"
>
{{ seq }}
<button class="text-white/30 hover:text-white/60" @click="removeStopSequence(i)">&times;</button>
</span>
</div>
<input
v-model="newStopSeq"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add stop sequence (Enter to add)"
@keydown.enter="addStopSequence"
/>
</div>
<button
class="text-[11px] text-white/30 hover:text-white/50 transition-colors"
@click="resetDefaults"
>
Reset to defaults
</button>
</template>
<p v-else class="text-xs text-white/30">Start a conversation to configure parameters</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useMemoryStore, type MemoryItem } from '@/stores/memory'
import { useChatStore } from '@/stores/chat'
defineProps<{
open: boolean
}>()
defineEmits<{
'update:open': [value: boolean]
}>()
// --- Memory ---
const memoryStore = useMemoryStore()
const newMemoryText = ref('')
const editingId = ref<string | null>(null)
const editText = ref('')
function addMemory() {
if (!newMemoryText.value.trim()) return
memoryStore.addItem(newMemoryText.value)
newMemoryText.value = ''
}
function startEdit(item: MemoryItem) {
editingId.value = item.id
editText.value = item.text
}
function saveEdit(id: string) {
if (editText.value.trim()) {
memoryStore.updateItem(id, editText.value)
}
editingId.value = null
editText.value = ''
}
function cancelEdit() {
editingId.value = null
editText.value = ''
}
// --- Advanced ---
const chatStore = useChatStore()
const conv = computed(() => chatStore.activeConversation)
const newStopSeq = ref('')
const temperature = ref(1.0)
const maxTokens = ref(4096)
const topP = ref(1.0)
const stopSequences = ref<string[]>([])
watch(
() => chatStore.activeConversationId,
() => loadFromConv(),
{ immediate: true }
)
function loadFromConv() {
const c = conv.value
temperature.value = c?.temperature ?? 1.0
maxTokens.value = c?.maxTokens ?? 4096
topP.value = c?.topP ?? 1.0
stopSequences.value = c?.stopSequences ? [...c.stopSequences] : []
}
function persistParams() {
const c = conv.value
if (!c) return
c.temperature = temperature.value
c.maxTokens = maxTokens.value
c.topP = topP.value
c.updatedAt = Date.now()
}
function addStopSequence() {
const seq = newStopSeq.value.trim()
if (!seq) return
stopSequences.value.push(seq)
newStopSeq.value = ''
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function removeStopSequence(index: number) {
stopSequences.value.splice(index, 1)
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function resetDefaults() {
temperature.value = 1.0
maxTokens.value = 4096
topP.value = 1.0
stopSequences.value = []
newStopSeq.value = ''
const c = conv.value
if (c) {
c.temperature = undefined
c.maxTokens = undefined
c.topP = undefined
c.stopSequences = undefined
c.updatedAt = Date.now()
}
}
</script>
<style scoped>
.settings-modal-enter-active {
transition: opacity 0.2s ease-out;
}
.settings-modal-enter-active .glass-card {
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
}
.settings-modal-leave-active {
transition: opacity 0.15s ease-in;
}
.settings-modal-enter-from,
.settings-modal-leave-to {
opacity: 0;
}
</style>
@@ -5,16 +5,10 @@
<span
v-for="i in 3"
:key="i"
class="w-1.5 h-1.5 rounded-full animate-pulse-glow"
:class="isDark ? 'bg-white/40' : 'bg-gray-400'"
class="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse-glow"
:style="{ animationDelay: `${i * 200}ms` }"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { isDark } = useTheme()
</script>
@@ -0,0 +1,181 @@
<template>
<div class="app-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden">
<div
class="w-full aspect-[16/7] flex items-center justify-center"
:style="{ background: appGradient }"
>
<span class="text-5xl font-bold text-white/20">{{ app.name.charAt(0) }}</span>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 p-2 rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4 text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ app.name }}</h2>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1 text-xs text-white/60">
<span
class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-white/15"
>{{ categoryLabel }}</span>
<span
v-for="p in app.platforms"
:key="p"
class="text-[10px]"
>{{ platformLabel(p) }}</span>
</div>
</div>
</div>
<div class="p-4 space-y-5">
<p class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ app.longDescription }}
</p>
<div v-if="app.howTo?.length">
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Getting Started</h4>
<ol class="space-y-2">
<li
v-for="(step, i) in app.howTo"
:key="i"
class="flex gap-2.5 text-xs"
>
<span
class="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'"
>{{ i + 1 }}</span>
<span :class="isDark ? 'text-white/70' : 'text-gray-600'">{{ step }}</span>
</li>
</ol>
</div>
<div>
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Open</h4>
<a
:href="app.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<div
class="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold"
:style="{ background: appGradient }"
>
<span class="text-white/90">{{ app.name.charAt(0) }}</span>
</div>
<div>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ app.url.replace(/^https?:\/\//, '') }}</p>
<p class="text-[10px]"
:class="isDark ? 'text-white/30' : 'text-gray-400'">Official website</p>
</div>
</div>
<svg class="w-4 h-4" :class="isDark ? 'text-white/30' : 'text-gray-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
<div v-if="relatedApps.length > 0">
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Related Apps</h4>
<div class="space-y-2">
<button
v-for="related in relatedApps"
:key="related.id"
class="w-full text-left flex items-center gap-2.5 p-2.5 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
@click="$emit('selectApp', related)"
>
<div
class="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold shrink-0"
:style="{ background: relatedGradient(related.id) }"
>
<span class="text-white/90">{{ related.name.charAt(0) }}</span>
</div>
<div class="min-w-0">
<p class="text-xs font-medium truncate"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ related.name }}</p>
<p class="text-[10px] truncate"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ related.description }}</p>
</div>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { APP_DATABASE, type AppEntry } from '@/data/apps'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{ app: AppEntry }>()
defineEmits<{ back: []; selectApp: [app: AppEntry] }>()
const { isDark } = useTheme()
const categoryLabels: Record<string, string> = {
'nostr-client': 'Nostr Client',
'lightning-wallet': 'Lightning Wallet',
'bitcoin-wallet': 'Bitcoin Wallet',
privacy: 'Privacy',
node: 'Node Software',
'dev-tool': 'Dev Tool',
relay: 'Relay',
}
const categoryLabel = computed(() => categoryLabels[props.app.category] ?? props.app.category)
function platformLabel(p: string): string {
const labels: Record<string, string> = {
ios: 'iOS',
android: 'Android',
web: 'Web',
desktop: 'Desktop',
cli: 'CLI',
nodeos: 'Node',
}
return labels[p] ?? p
}
function hashToHue(id: string): number {
let hash = 0
for (let i = 0; i < id.length; i++) hash = id.charCodeAt(i) + ((hash << 5) - hash)
return Math.abs(hash % 360)
}
const appGradient = computed(() => {
const hue = hashToHue(props.app.id)
return `linear-gradient(135deg, hsl(${hue}, 60%, 35%), hsl(${(hue + 40) % 360}, 50%, 25%))`
})
function relatedGradient(id: string): string {
const hue = hashToHue(id)
return `linear-gradient(135deg, hsl(${hue}, 60%, 35%), hsl(${(hue + 40) % 360}, 50%, 25%))`
}
const relatedApps = computed(() => {
if (!props.app.relatedApps?.length) return []
return props.app.relatedApps
.map(id => APP_DATABASE.find(a => a.id === id))
.filter((a): a is AppEntry => !!a)
})
</script>
@@ -0,0 +1,166 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3" :style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ title }}
</h3>
<span class="text-[10px] font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredApps.length }} apps
</span>
</div>
<input
v-model="search"
type="text"
placeholder="Search apps..."
class="w-full px-3 py-2 rounded-lg text-xs outline-none transition-colors"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
: 'bg-black/3 text-gray-800 placeholder:text-gray-400 focus:bg-black/5'"
/>
<div class="flex flex-wrap gap-1.5">
<button
v-for="cat in categories"
:key="cat.value"
class="text-[10px] px-2 py-1 rounded-md transition-all duration-150"
:class="activeCategory === cat.value
? 'nav-tab-active'
: isDark
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
@click="activeCategory = activeCategory === cat.value ? null : cat.value"
>
{{ cat.label }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="space-y-2">
<button
v-for="app in filteredApps"
:key="app.id"
class="w-full text-left p-3 rounded-xl transition-all duration-200 flex items-start gap-3"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
@click="$emit('selectApp', app)"
>
<div
class="w-10 h-10 rounded-xl flex items-center justify-center text-lg font-bold shrink-0"
:style="{ background: appGradient(app.id) }"
>
<span class="text-white/90">{{ app.name.charAt(0) }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-xs font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ app.name }}
</p>
<span
class="text-[9px] px-1.5 py-0.5 rounded font-medium shrink-0"
:class="isDark ? 'bg-white/10 text-white/50' : 'bg-black/5 text-gray-500'"
>
{{ categoryLabel(app.category) }}
</span>
</div>
<p class="text-[11px] mt-0.5 line-clamp-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ app.description }}
</p>
<div class="flex gap-1 mt-1.5">
<span
v-for="p in app.platforms"
:key="p"
class="text-[8px] px-1 py-0.5 rounded"
:class="isDark ? 'bg-white/5 text-white/30' : 'bg-black/3 text-gray-400'"
>
{{ platformLabel(p) }}
</span>
</div>
</div>
</button>
</div>
<div v-if="filteredApps.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No apps match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { AppEntry } from '@/data/apps'
import { useTheme } from '@/composables/useTheme'
const props = withDefaults(defineProps<{
apps: AppEntry[]
title?: string
}>(), {
title: 'Recommended Apps',
})
defineEmits<{ selectApp: [app: AppEntry] }>()
const { isDark } = useTheme()
const search = ref('')
const activeCategory = ref<string | null>(null)
const categories = [
{ value: 'nostr-client', label: 'Nostr' },
{ value: 'lightning-wallet', label: 'Lightning' },
{ value: 'bitcoin-wallet', label: 'Bitcoin' },
{ value: 'privacy', label: 'Privacy' },
{ value: 'node', label: 'Nodes' },
{ value: 'dev-tool', label: 'Dev' },
]
function categoryLabel(cat: string): string {
return categories.find(c => c.value === cat)?.label ?? cat
}
function platformLabel(p: string): string {
const labels: Record<string, string> = {
ios: 'iOS',
android: 'Android',
web: 'Web',
desktop: 'Desktop',
cli: 'CLI',
nodeos: 'Node',
}
return labels[p] ?? p
}
function appGradient(id: string): string {
let hash = 0
for (let i = 0; i < id.length; i++) hash = id.charCodeAt(i) + ((hash << 5) - hash)
const hue = Math.abs(hash % 360)
return `linear-gradient(135deg, hsl(${hue}, 60%, 35%), hsl(${(hue + 40) % 360}, 50%, 25%))`
}
const filteredApps = computed(() => {
let result = props.apps
if (search.value) {
const q = search.value.toLowerCase()
result = result.filter(
a => a.name.toLowerCase().includes(q) ||
a.description.toLowerCase().includes(q) ||
a.keywords.some(k => k.toLowerCase().includes(q))
)
}
if (activeCategory.value) {
result = result.filter(a => a.category === activeCategory.value)
}
return result
})
</script>
@@ -82,6 +82,12 @@
:podcast="selectedPodcast"
@back="closePodcastDetail"
/>
<AppDetail
v-else-if="selectedApp"
:app="selectedApp"
@back="closeAppDetail"
@select-app="openAppDetail"
/>
<ArticleDetail
v-else-if="selectedArticle"
:article="selectedArticle"
@@ -159,6 +165,12 @@
:query="panelQuery"
:hero-image="panelMagazineHeroImage ?? undefined"
/>
<AppsGrid
v-else-if="activeTab === 'app'"
:apps="panelApps"
:title="panelTitle"
@select-app="openAppDetail"
/>
<ProjectGrid
v-else-if="activeTab === 'code'"
/>
@@ -194,6 +206,8 @@ import ArticleReader from '@/components/renderers/ArticleReader.vue'
import PdfViewer from '@/components/renderers/PdfViewer.vue'
import MapRenderer from '@/components/renderers/MapRenderer.vue'
import MagazineGrid from './MagazineGrid.vue'
import AppsGrid from './AppsGrid.vue'
import AppDetail from './AppDetail.vue'
import ProjectGrid from './ProjectGrid.vue'
import NostrGrid from './NostrGrid.vue'
import FavoritesGrid from './FavoritesGrid.vue'
@@ -217,6 +231,7 @@ const {
panelPodcasts,
panelWebResults,
panelWebsites,
panelApps,
panelMagazineSections,
panelMagazineHeroImage,
panelTitle,
@@ -229,6 +244,7 @@ const {
selectedSong,
selectedPodcast,
selectedArticle,
selectedApp,
selectedDesignSystemItem,
setActiveTab,
openFilmDetail,
@@ -241,6 +257,8 @@ const {
closeSongDetail,
openPodcastDetail,
closePodcastDetail,
openAppDetail,
closeAppDetail,
closeArticleDetail,
longFormArticle,
closeLongFormArticle,
@@ -252,7 +270,7 @@ const {
} = useContentPanel()
const hasDetailOpen = computed(() =>
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedDesignSystemItem.value || longFormArticle.value || pdfUrl.value || mapPlaces.value.length > 0)
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedApp.value || selectedDesignSystemItem.value || longFormArticle.value || pdfUrl.value || mapPlaces.value.length > 0)
)
const windowWidth = ref(window.innerWidth)
@@ -286,6 +304,7 @@ const TAB_LABELS: Record<ContentTab, string> = {
magazine: 'Brief',
code: 'Code',
'design-system': 'Design',
app: 'Apps',
nostr: 'Nostr',
favorites: 'Favorites',
discover: 'Discover',
@@ -90,14 +90,13 @@
<!-- Component preview (rendered as styled blocks) -->
<div v-else class="space-y-2">
<!-- Glass button preview -->
<div v-if="item.id === 'atom-glass-btn'" class="flex gap-2">
<div v-if="item.id === 'atom-glass-btn'" class="flex gap-3">
<button class="glass-button text-sm">Action</button>
<button class="glass-button text-sm opacity-50 cursor-not-allowed">Disabled</button>
</div>
<div v-else-if="item.id === 'atom-glass-btn-sm'" class="flex gap-2">
<button class="glass-button-sm text-xs">Small</button>
</div>
<div v-else-if="item.id === 'atom-gradient-btn'" class="flex gap-2">
<button class="gradient-button text-sm">Primary Action</button>
<div v-else-if="item.id === 'atom-glass-btn-sm'" class="flex gap-3">
<button class="glass-button glass-button-sm text-xs">Small</button>
<button class="glass-button glass-button-sm text-xs opacity-50 cursor-not-allowed">Disabled</button>
</div>
<div v-else-if="item.id === 'atom-icon-btn'" class="flex gap-3">
<button class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center"
@@ -129,8 +129,7 @@ const items: DesignSystemItem[] = [
// Atoms
{ id: 'atom-glass-btn', name: 'Glass Button', category: 'atoms', description: '48px height, glass morphism background', code: '<button class="glass-button">\n Action\n</button>\n\n/* glass-button:\n height: 48px\n background: rgba(0,0,0,0.6)\n backdrop-filter: blur(18px)\n border-radius: 12px\n border: 1px solid rgba(255,255,255,0.12)\n*/', usedIn: 'ChatInput send, modal actions, primary controls' },
{ id: 'atom-glass-btn-sm', name: 'Glass Button Small', category: 'atoms', description: 'Compact glass button variant', code: '<button class="glass-button-sm">\n Small\n</button>\n\n/* Compact variant of glass-button */', usedIn: 'ChatInput send/stop buttons, inline actions' },
{ id: 'atom-gradient-btn', name: 'Gradient Button', category: 'atoms', description: 'Primary CTA with accent gradient', code: '<button class="gradient-button">\n Primary Action\n</button>\n\n/* gradient-button:\n background: linear-gradient(135deg, #F7931A, #e8850f)\n height: 48px\n border-radius: 12px\n font-weight: 600\n*/', usedIn: 'Primary CTAs, onboarding, confirmation dialogs' },
{ id: 'atom-icon-btn', name: 'Icon Button', category: 'atoms', description: 'Path glass icon, 32-36px square', code: '<button class="w-9 h-9 rounded-xl path-glass-icon\n flex items-center justify-center">\n <svg class="w-4 h-4" ...>\n</button>\n\n/* path-glass-icon:\n background: transparent\n transition: colors\n hover: bg-white/10\n*/', usedIn: 'ChatHeader toolbar, detail back buttons, close buttons' },
{ id: 'atom-icon-btn', name: 'Icon Button', category: 'atoms', description: 'Path glass icon, 32-36px square', code: '<button class="w-9 h-9 rounded-xl path-glass-icon\n flex items-center justify-center">\n <svg class="w-4 h-4" ...>\n</button>\n\n/* path-glass-icon:\n background: transparent\n transition: colors\n hover: bg-white/10\n*/', usedIn: 'ChatHeader toolbar, detail back buttons, close buttons' },
{ id: 'atom-badge', name: 'Genre Badge', category: 'atoms', description: 'Tiny pill badge for tags/genres', code: '<span class="text-[10px] px-2 py-1 rounded-md\n font-medium bg-white/10 text-white/60">\n Science Fiction\n</span>', usedIn: 'FilmGrid, SongGrid, BookGrid, TVSeriesGrid genre filters' },
{ id: 'atom-nav-tab', name: 'Nav Tab', category: 'atoms', description: 'Content panel tab with active state', code: '<button class="nav-tab-active">\n Films\n</button>\n\n/* Active: accent underline\n Inactive: text-white/50 hover:text-white\n Transition: 200ms */', usedIn: 'ContentPanel tab bar, mobile content tab filters' },
{ id: 'atom-input', name: 'Text Input', category: 'atoms', description: 'Search/filter input field', code: '<input\n class="w-full px-3 py-2 rounded-lg text-xs\n outline-none transition-colors\n bg-white/5 text-white/80\n placeholder:text-white/25\n focus:bg-white/10"\n placeholder="Search..."\n/>', usedIn: 'All grid search bars, ProjectGrid new project' },
@@ -68,7 +68,7 @@
Skip
</button>
<button
class="gradient-button flex-1 h-10 rounded-xl text-sm font-medium text-white transition-opacity"
class="glass-button flex-1 h-10 rounded-xl text-sm font-medium text-white transition-opacity"
:disabled="!canSubmit"
:class="{ 'opacity-40 cursor-not-allowed': !canSubmit }"
@click="handleSubmit"
@@ -1143,3 +1143,84 @@ export function extractEvents(text: string): EventData[] {
export function stripEventTags(text: string): string {
return text.replace(EVENT_EXT_RE, '').replace(/\n{3,}/g, '\n\n').trim()
}
// ─── Bare domain extraction ──────────────────────────────────────
const KNOWN_TLDS = /\.(com|org|net|io|co|app|dev|xyz|social|news|info|me|tv|fm|live|chat|fyi|ai|so|world|land|pub|lol|cafe|money|exchange|market|tech|design|page|site|online|store|cloud|network|community|foundation)$/i
const FILE_EXT_BLOCK = /\.(js|ts|css|html|json|md|txt|pdf|png|jpg|jpeg|gif|svg|vue|yaml|yml|xml|csv|sql|sh|py|rb|go|rs|toml|lock|env|log|map|wasm|woff2?|ttf|eot|ico)$/i
export function extractBareDomainLinks(text: string): WebSearchResult[] {
const results: WebSearchResult[] = []
const seen = new Set<string>()
// Mark positions already covered by markdown links, bold-domain patterns, and full URLs
const coveredRanges: [number, number][] = []
let m: RegExpExecArray | null
const mdRe = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g
while ((m = mdRe.exec(text)) !== null) coveredRanges.push([m.index, m.index + m[0].length])
const boldRe = /\*\*([^*]+)\*\*\s*\(([a-zA-Z0-9][-a-zA-Z0-9.]*\.[a-zA-Z]{2,})\)/g
while ((m = boldRe.exec(text)) !== null) coveredRanges.push([m.index, m.index + m[0].length])
const fullUrlRe = /https?:\/\/[^\s)\]"'<>,]+/g
while ((m = fullUrlRe.exec(text)) !== null) coveredRanges.push([m.index, m.index + m[0].length])
function isCovered(idx: number, len: number): boolean {
return coveredRanges.some(([start, end]) => idx >= start && idx + len <= end)
}
const domainRe = /\b([a-zA-Z][a-zA-Z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9-]*)*\.[a-zA-Z]{2,})\b/g
while ((m = domainRe.exec(text)) !== null) {
if (isCovered(m.index, m[0].length)) continue
const domain = m[1]
if (!KNOWN_TLDS.test(domain)) continue
if (FILE_EXT_BLOCK.test(domain)) continue
if (/^\d/.test(domain)) continue
const url = `https://${domain}`
const norm = normUrl(url)
if (seen.has(norm)) continue
seen.add(norm)
const title = domain.charAt(0).toUpperCase() + domain.slice(1)
results.push({ title, url, content: undefined })
}
return results
}
// ─── App extraction ──────────────────────────────────────────────
import { APP_DATABASE, type AppEntry } from '@/data/apps'
import { isAppQuery, isNostrQuery } from './contentFiltering'
export { type AppEntry } from '@/data/apps'
export function extractApps(text: string, userQuery: string): AppEntry[] {
const lower = text.toLowerCase()
const matched: AppEntry[] = []
const seen = new Set<string>()
for (const app of APP_DATABASE) {
const allKeywords = [app.name.toLowerCase(), ...app.keywords.map(k => k.toLowerCase())]
for (const kw of allKeywords) {
if (kw.length < 3) continue
const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = kw.length <= 4
? new RegExp(`\\b${escaped}\\b`, 'i')
: new RegExp(escaped, 'i')
if (re.test(lower) && !seen.has(app.id)) {
seen.add(app.id)
matched.push(app)
break
}
}
}
// Surface apps if: app/nostr query with 1+, or 2+ apps detected in any context
const isAppContext = isAppQuery(userQuery) || isNostrQuery(userQuery)
if (isAppContext && matched.length >= 1) return matched
if (matched.length >= 2) return matched
return []
}
+103 -15
View File
@@ -1,4 +1,4 @@
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'news' | 'websites' | 'magazine' | 'code' | 'design-system' | 'nostr' | 'favorites' | 'discover'
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'news' | 'websites' | 'magazine' | 'code' | 'design-system' | 'nostr' | 'app' | 'favorites' | 'discover'
export interface MagazineSection {
title: string
@@ -14,37 +14,47 @@ export interface MagazineSection {
export function isNewsQuery(q: string): boolean {
const lower = q.toLowerCase().trim()
if (!lower) return false
return /\b(news|latest|recent|current|what'?s happening|updates? about)\b/.test(lower) ||
return /\b(news|latest|recent|current|what'?s happening|updates? about|headlines?|breaking|press|media coverage)\b/.test(lower) ||
/what'?s the latest|latest \w+ news/.test(lower) ||
/what are people saying|what'?s the word|what do people think/.test(lower)
/what are people saying|what'?s the word|what do people think/.test(lower) ||
/what happened (today|this week|recently|yesterday)|any updates? on|what'?s (new|going on)|trending|in the news/i.test(lower) ||
/current events|today'?s top|catch me up|brief me|fill me in/i.test(lower)
}
export function isNewsLikeResponse(text: string): boolean {
const lower = text.toLowerCase()
return /for instant .* news|check these sources|for (the )?latest (bitcoin )?news|direct sources/i.test(lower) ||
/(have )?access to (live )?web search|want me to (go back and )?search/i.test(lower)
/(have )?access to (live )?web search|want me to (go back and )?search/i.test(lower) ||
/i can'?t access the web.{0,30}(but|however)|having trouble reaching the web.{0,30}(but|however)|unable to browse.{0,30}(but|however)|can'?t search the web.{0,30}(but|however)/i.test(lower) ||
/here are.{0,20}(reliable|trusted|good) sources|top sources for/i.test(lower)
}
export function isMusicQuery(q: string): boolean {
if (!q) return false
return /\b(song|songs|music|track|tracks|playlist|album|albums|listen|listening|sing|singing|singer|band|bands|artist|artists|rapper|rap|hip hop|r&b|rock|jazz|classical|edm|electronic|pop music|concert|vinyl|soundtrack|anthem|beat|beats|melody|melodies|tune|tunes|lyric|lyrics|acoustic|remix|dj)\b/i.test(q) ||
return /\b(song|songs|music|track|tracks|playlist|playlists|album|albums|listen|listening|sing|singing|singer|singers|band|bands|artist|artists|rapper|rappers|rap|hip hop|r&b|rock|jazz|classical|edm|electronic|pop music|concert|concerts|vinyl|soundtrack|anthem|beat|beats|melody|melodies|tune|tunes|lyric|lyrics|acoustic|remix|dj|genre|genres|spotify|soundcloud|bandcamp|musician|musicians|composer|composers|orchestra|symphony|punk|metal|reggae|blues|soul|funk|country music|grammys?|billboard|top 40|mixtape|ep\b|lp\b|discography|jam|jams|banger|bangers)\b/i.test(q) ||
/recommend.*(song|music|track|listen)/i.test(q) ||
/play\s+(me\s+)?(some|a)\b/i.test(q)
/play\s+(me\s+)?(some|a)\b/i.test(q) ||
/what genre|favorite (song|music|band|artist|jam|tune)|best (song|album|track|music)/i.test(q)
}
export function isWebsitesQuery(q: string): boolean {
const lower = q.toLowerCase().trim()
return /\b(website|websites|where to check|best places? to check|places? to (look|check|find)|check online|resources?|sources? to (check|read|visit))\b/.test(lower) ||
/where (can i|should i) (check|look|find)/.test(lower)
return /\b(website|websites|where to check|best places? to check|places? to (look|check|find)|check online|resources?|sources? to (check|read|visit)|links?|urls?|sites?|portals?|platforms? for|tools? for|apps? for|services? for)\b/.test(lower) ||
/where (can i|should i) (check|look|find|go|visit|browse)/.test(lower) ||
/point me to|direct me to|link me|send me (to|a link|some links)|any good (sites|resources|tools|platforms)/i.test(lower)
}
export function isWebsitesLikeResponse(text: string): boolean {
const lower = text.toLowerCase()
return /best places? to check|check online yourself|places? to check online|websites? to (visit|check|read)/i.test(lower)
return /best places? to check|check online yourself|places? to check online|websites? to (visit|check|read)/i.test(lower) ||
/here are (some|a few|the best) (resources|websites|sites|links|tools|platforms)/i.test(lower) ||
/i'?d recommend (checking|visiting|looking at)|you (can|could|should|might) (check|visit|try|look at|browse)/i.test(lower) ||
/useful (resources|websites|sites|links|tools)|helpful (resources|websites|sites|links)/i.test(lower)
}
export function isBookQuery(q: string): boolean {
return /\b(book|books|read|reading|novel|novels|author|nonfiction|non-fiction|recommend.*read|must.read|literature)\b/i.test(q)
return /\b(book|books|read|reading|novel|novels|author|authors|nonfiction|non-fiction|recommend.*read|must.read|literature|memoir|memoirs|biography|biographies|autobiography|paperback|hardcover|kindle|audible|audiobook|audiobooks|bookshelf|bestseller|bestsellers|goodreads|epub)\b/i.test(q) ||
/what should i read|favorite reads?|reading list|book club|book recommendation|suggest.*book|what.*worth reading|good reads?|anything to read|currently reading/i.test(q)
}
export function isBookLikeResponse(text: string): boolean {
@@ -53,7 +63,8 @@ export function isBookLikeResponse(text: string): boolean {
}
export function isTVQuery(q: string): boolean {
return /\b(tv show|tv series|series|television|streaming|binge|watch|recommend.*show|best show|season)\b/i.test(q)
return /\b(tv show|tv series|series|television|streaming|binge|watch|recommend.*show|best show|season|netflix|hbo|hulu|disney\+?|apple tv|amazon prime|peacock|paramount\+?|showtime|miniseries|docuseries|sitcom|drama series|limited series|pilot|showrunner|renewed|cancelled|premiere)\b/i.test(q) ||
/what'?s good on|anything to (binge|watch)|what should (i|we) (watch|stream)|good (show|series) to|new (show|series)|best (show|series)|recommend.*(show|series|watch)/i.test(q)
}
export function isImageQuery(q: string): boolean {
@@ -61,7 +72,8 @@ export function isImageQuery(q: string): boolean {
}
export function isPlaceQuery(q: string): boolean {
return /\b(restaurant|restaurants|place|places|food|eat|eating|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|where to eat|good food|best food|where should i eat|recommend.*eat|recommend.*restaurant|recommend.*place)\b/i.test(q)
return /\b(restaurant|restaurants|place|places|food|eat|eating|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|where to eat|good food|best food|where should i eat|recommend.*eat|recommend.*restaurant|recommend.*place|hungry|starving|takeout|take-?out|delivery|reservation|reservations|michelin|yelp|zagat|foodie|gastropub|tapas|dim sum|bbq|barbecue|food truck|brewery|winery|cocktail bar|speakeasy|rooftop bar|happy hour)\b/i.test(q) ||
/where.*(eat|food|drink|grab|dine)|best.*(brunch|lunch|dinner|food|restaurant|eat|spot)|good.*(food|restaurant|eat|spot)|what'?s good to eat/i.test(q)
}
export function isPlaceLikeResponse(text: string): boolean {
@@ -69,6 +81,57 @@ export function isPlaceLikeResponse(text: string): boolean {
(text.match(/\b(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill)\b/gi)?.length ?? 0) >= 2
}
// ─── Nostr classifiers ──────────────────────────────────────────
export function isNostrQuery(q: string): boolean {
if (!q) return false
return /\b(nostr|npub[a-z0-9]{8,}|nip-?\d+|damus|primal|snort|amethyst|coracle|iris|nos\.social|zaps?\b|relays?\b|naddr|nevent|nprofile|note1[a-z0-9]+|nostrich|fiatjaf)\b/i.test(q) ||
/\b(social media|social network|social protocol)\b.*\b(decentrali|censorship|relay|open)\b/i.test(q) ||
/decentralized social|censorship.resistant.*(social|network|protocol)/i.test(q)
}
export function isNostrLikeResponse(text: string): boolean {
// Literal "nostr" is always sufficient
if (/\bnostr\b/i.test(text)) return true
// Otherwise require 2+ distinct Nostr-specific signals
const signals = [
/\bnpub[a-z0-9]{8,}\b/i,
/\bnip-?\d+\b/i,
/\b(damus|primal|snort|amethyst|coracle|iris|nos\.social|nostrudel)\b/i,
/\b(relay|relays)\b.*\b(wss?:\/\/|connect|publish)\b/i,
/\bzaps?\b.*\b(lightning|sats|send)\b/i,
/\bnote1[a-z0-9]+\b/i,
/\bnevent[a-z0-9]+\b/i,
/\bnprofile[a-z0-9]+\b/i,
/\bnostrich\b/i,
/\bfiatjaf\b/i,
]
return signals.filter(re => re.test(text)).length >= 2
}
// ─── App classifiers ────────────────────────────────────────────
export function isAppQuery(q: string): boolean {
if (!q) return false
return /\b(app|apps|application|applications|client|clients|wallet|wallets|tool|tools|software|download|install)\b/i.test(q) &&
/\b(best|good|recommend|suggest|which|what|top|favorite|popular|use|try|need)\b/i.test(q) ||
/what app|which app|best app|recommend.*app|suggest.*app|best.*client|best.*wallet|recommend.*wallet|recommend.*tool/i.test(q) ||
/what.*(use|download|install) for|how (do i|to) (use|get|install|set up)/i.test(q)
}
export function isAppLikeResponse(text: string): boolean {
const lower = text.toLowerCase()
const appSignals = [
/popular (clients?|apps?|wallets?|tools?) include/i,
/you (can|could|might|should) (use|try|check out|download|install)/i,
/available (on|for) (ios|android|web|desktop|mac|windows|linux)/i,
/download (from|on|at)/i,
/(app store|play store|google play|f-?droid|github releases?)/i,
/open.?source.*(app|client|tool|wallet)/i,
]
return appSignals.filter(re => re.test(lower)).length >= 2
}
// ─── Tab filtering ────────────────────────────────────────────────
export function extractQueryContext(q: string): string {
@@ -85,7 +148,9 @@ export function preferredFirstTab(userQuery: string): ContentTab | null {
if (/\b(book|books|read|reading|novel|author|nonfiction|non-fiction)\b/.test(q)) return 'book'
if (/\b(tv show|tv series|series|television|streaming|binge|watch)\b/.test(q)) return 'tvshow'
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse)\b/.test(q)) return 'place'
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|hungry)\b/.test(q)) return 'place'
if (isAppQuery(q)) return 'app'
if (isNostrQuery(q)) return 'nostr'
if (isNewsQuery(q)) return 'news'
if (isWebsitesQuery(q)) return 'websites'
return null
@@ -103,10 +168,31 @@ export function filterTabsByContext(
hasNews: boolean,
hasWebsites: boolean,
hasMagazine: boolean,
hasNostr: boolean,
hasApps: boolean,
): ContentTab[] {
const q = userQuery.toLowerCase().trim()
const preferred = preferredFirstTab(userQuery)
// Nostr query → prioritize nostr tab with magazine/websites/apps as secondary
if (isNostrQuery(q)) {
const tabs: ContentTab[] = ['nostr']
if (hasApps) tabs.push('app')
if (hasMagazine) tabs.push('magazine')
if (hasWebsites) tabs.push('websites')
return tabs
}
// App query → prioritize apps tab
if (isAppQuery(q)) {
const tabs: ContentTab[] = []
if (hasApps) tabs.push('app')
if (hasNostr) tabs.push('nostr')
if (hasMagazine) tabs.push('magazine')
if (hasWebsites) tabs.push('websites')
return tabs.length > 0 ? tabs : hasNostr ? ['nostr'] : []
}
if (isNewsQuery(q)) {
const tabs: ContentTab[] = []
if (hasMagazine) tabs.push('magazine')
@@ -116,11 +202,11 @@ export function filterTabsByContext(
return tabs
}
if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasWebsites) {
if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasWebsites && !hasNostr && !hasApps) {
return ['magazine']
}
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasMagazine) {
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasMagazine && !hasNostr && !hasApps) {
return ['websites']
}
@@ -132,9 +218,11 @@ export function filterTabsByContext(
if (hasPlaces) all.push('place')
if (hasSongs) all.push('song')
if (hasPodcasts) all.push('podcast')
if (hasApps) all.push('app')
if (hasMagazine) all.push('magazine')
if (hasNews) all.push('news')
if (hasWebsites) all.push('websites')
if (hasNostr) all.push('nostr')
if (preferred && all.includes(preferred)) {
const rest = all.filter((t) => t !== preferred)
@@ -9,12 +9,15 @@ import {
extractAllFilms, extractAllSongs, extractAllPodcasts, extractAllBooks,
extractAllTVSeries, extractAllImages, extractAllPlaces,
extractMagazineSections, extractMagazineHeroImage,
extractMarkdownLinks, extractBoldDomainLinks, mergeNewsResults,
extractMarkdownLinks, extractBoldDomainLinks, extractBareDomainLinks, mergeNewsResults,
extractFilmIds, extractSongIds, extractPodcastIds,
extractApps,
stripFilmTags, stripSongTags, stripPodcastTags, stripContentTags, stripMarkdownLinks,
} from './contentExtraction'
import type { AppEntry } from './contentExtraction'
import {
isNewsQuery, isNewsLikeResponse, isTVQuery,
isNostrQuery, isNostrLikeResponse,
filterTabsByContext, extractQueryContext,
} from './contentFiltering'
export type { ContentTab, MagazineSection } from './contentFiltering'
@@ -33,6 +36,7 @@ const panelSongs = ref<Song[]>([])
const panelPodcasts = ref<Podcast[]>([])
const panelImages = ref<ImageItem[]>([])
const panelPlaces = ref<Place[]>([])
const panelApps = ref<AppEntry[]>([])
const selectedFilm = ref<Film | null>(null)
const selectedBook = ref<Book | null>(null)
const selectedTVSeries = ref<TVSeries | null>(null)
@@ -42,6 +46,7 @@ const selectedArticle = ref<WebSearchResult | null>(null)
const selectedImage = ref<ImageItem | null>(null)
const selectedPlace = ref<Place | null>(null)
const selectedWebsite = ref<WebSearchResult | null>(null)
const selectedApp = ref<AppEntry | null>(null)
const selectedMagazineSection = ref<MagazineSection | null>(null)
const magazineSectionIndex = ref(0)
const panelTitle = ref('Recommended Films')
@@ -77,14 +82,22 @@ export function useContentPanel() {
}
const fromMarkdown = extractMarkdownLinks(text)
const boldDomains = extractBoldDomainLinks(text)
const bareDomains = extractBareDomainLinks(text)
panelRssArticles.value = []
const hasLinkableContent = fromMarkdown.length > 0 || boldDomains.length > 0
const hasLinkableContent = fromMarkdown.length > 0 || boldDomains.length > 0 || bareDomains.length > 0
const websitesFromMarkdown = hasLinkableContent ? fromMarkdown : []
const mergedWebsites = mergeNewsResults(websitesFromMarkdown, boldDomains)
const mergedWebsites = mergeNewsResults(mergeNewsResults(websitesFromMarkdown, boldDomains), bareDomains)
const hasWebsites = mergedWebsites.length > 0
// App extraction
const apps = extractApps(text, userQuery)
const hasApps = apps.length > 0
// Nostr detection
const hasNostr = isNostrQuery(userQuery) || isNostrLikeResponse(text)
const images = extractAllImages(text, userQuery)
const places = extractAllPlaces(text, userQuery)
@@ -119,7 +132,7 @@ export function useContentPanel() {
})
}
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine)
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine, hasNostr, hasApps)
availableTabs.value = tabs.length > 0 ? tabs : ['film']
activeTab.value = tabs[0] ?? 'film'
@@ -133,6 +146,7 @@ export function useContentPanel() {
const showNews = tabs.includes('news')
const showWebsitesTab = tabs.includes('websites')
const showMagazine = tabs.includes('magazine')
const showApps = tabs.includes('app')
const visibleFilms = showFilms ? films : []
const visibleBooks = showBooks ? books : []
@@ -144,6 +158,7 @@ export function useContentPanel() {
const visibleNews = showNews ? mergedNews : []
const visibleWebsites = showWebsitesTab ? mergedWebsites : []
const visibleMagazineSections = showMagazine ? magazineSections : []
const visibleApps = showApps ? apps : []
panelFilms.value = visibleFilms
panelBooks.value = visibleBooks
@@ -154,6 +169,7 @@ export function useContentPanel() {
panelPodcasts.value = visiblePodcasts
panelWebResults.value = visibleNews
panelWebsites.value = visibleWebsites
panelApps.value = visibleApps
panelMagazineSections.value = visibleMagazineSections
panelMagazineHeroImage.value = showMagazine
? (extractMagazineHeroImage(text) ?? webResults[0]?.imgSrc ?? null)
@@ -166,6 +182,7 @@ export function useContentPanel() {
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
selectedApp.value = null
selectedWebsite.value = null
selectedMagazineSection.value = null
@@ -207,7 +224,9 @@ export function useContentPanel() {
const ctx = extractQueryContext(userQuery)
panelTitle.value = ctx ? `${ctx} — Brief` : 'AI Brief'
}
else if (visibleApps.length > 0) panelTitle.value = `${visibleApps.length} Apps`
else if (visibleWebsites.length > 0) panelTitle.value = `${visibleWebsites.length} Websites`
else if (hasNostr) panelTitle.value = 'Nostr'
else panelTitle.value = 'Content'
panelOpen.value = tabs.length > 0
@@ -230,12 +249,16 @@ export function useContentPanel() {
const magazineSections = extractMagazineSections(text)
const fromMarkdown = extractMarkdownLinks(text)
const boldDomains = extractBoldDomainLinks(text)
const bareDomains = extractBareDomainLinks(text)
const hasNews = webResults.length > 0 && (isNewsQuery(userQuery) || isNewsLikeResponse(text))
const newsLinks = hasNews ? webResults : []
const hasLinkableContent = fromMarkdown.length > 0 || boldDomains.length > 0
const hasLinkableContent = fromMarkdown.length > 0 || boldDomains.length > 0 || bareDomains.length > 0
const websitesFromMd = hasLinkableContent ? fromMarkdown : []
const websitesLinks = mergeNewsResults(websitesFromMd, boldDomains)
const websitesLinks = mergeNewsResults(mergeNewsResults(websitesFromMd, boldDomains), bareDomains)
const hasWebsites = websitesLinks.length > 0
const apps = extractApps(text, userQuery)
const hasApps = apps.length > 0
const hasNostr = isNostrQuery(userQuery) || isNostrLikeResponse(text)
const images = extractAllImages(text, userQuery)
const places = extractAllPlaces(text, userQuery)
const hasAnyOtherInline = films.length > 0 || songs.length > 0 || podcasts.length > 0 ||
@@ -245,7 +268,7 @@ export function useContentPanel() {
/sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening|ETF|inflow|trading at|key developments|price recovery|institutional|analyst watch|market cap/i.test(text) ||
(!hasAnyOtherInline && !hasWebsites && magazineSections.length >= 2)
)
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine)
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine, hasNostr, hasApps)
return {
films: tabs.includes('film') ? films : [],
books: tabs.includes('book') ? books : [],
@@ -257,6 +280,8 @@ export function useContentPanel() {
newsLinks: tabs.includes('news') ? newsLinks : [],
websitesLinks: tabs.includes('websites') ? websitesLinks : [],
magazineSections: tabs.includes('magazine') ? magazineSections : [],
apps: tabs.includes('app') ? apps : [],
hasNostr,
}
}
@@ -271,6 +296,7 @@ export function useContentPanel() {
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
selectedApp.value = null
selectedWebsite.value = null
selectedMagazineSection.value = null
selectedDesignSystemItem.value = null
@@ -333,6 +359,9 @@ export function useContentPanel() {
function openPlaceDetail(place: Place) { clearAllSelections(); selectedPlace.value = place }
function closePlaceDetail() { selectedPlace.value = null }
function openAppDetail(app: AppEntry) { clearAllSelections(); selectedApp.value = app }
function closeAppDetail() { selectedApp.value = null }
function openDesignSystemItem(item: DesignSystemItem) { clearAllSelections(); selectedDesignSystemItem.value = item }
function closeDesignSystemItem() { selectedDesignSystemItem.value = null }
@@ -392,6 +421,7 @@ export function useContentPanel() {
panelPodcasts,
panelWebResults,
panelWebsites,
panelApps,
panelMagazineSections,
panelMagazineHeroImage,
selectedFilm,
@@ -402,6 +432,7 @@ export function useContentPanel() {
selectedSong,
selectedPodcast,
selectedArticle,
selectedApp,
selectedWebsite,
selectedMagazineSection,
magazineSectionIndex,
@@ -438,6 +469,8 @@ export function useContentPanel() {
closeArticleDetail,
openWebsiteDetail,
closeWebsiteDetail,
openAppDetail,
closeAppDetail,
openMagazineSectionDetail,
closeMagazineSectionDetail,
navigateMagazineSection,
@@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useTheme } from './useTheme'
describe('useTheme', () => {
beforeEach(() => {
localStorage.clear()
document.documentElement.classList.remove('dark', 'light')
// Reset module-level state by setting to default
const { setTheme } = useTheme()
setTheme('dark')
vi.restoreAllMocks()
})
it('returns expected API', () => {
const theme = useTheme()
expect(theme.currentTheme).toBeDefined()
expect(theme.isDark).toBeDefined()
expect(theme.setTheme).toBeTypeOf('function')
expect(theme.toggleTheme).toBeTypeOf('function')
expect(theme.initTheme).toBeTypeOf('function')
})
it('defaults to dark theme', () => {
const { isDark, currentTheme } = useTheme()
expect(currentTheme.value).toBe('dark')
expect(isDark.value).toBe(true)
})
it('setTheme switches to light', () => {
const { setTheme, isDark, currentTheme } = useTheme()
setTheme('light')
expect(currentTheme.value).toBe('light')
expect(isDark.value).toBe(false)
expect(localStorage.getItem('aiui-theme')).toBe('light')
expect(document.documentElement.classList.contains('light')).toBe(true)
expect(document.documentElement.classList.contains('dark')).toBe(false)
})
it('setTheme switches to dark', () => {
const { setTheme } = useTheme()
setTheme('light')
setTheme('dark')
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(document.documentElement.classList.contains('light')).toBe(false)
expect(localStorage.getItem('aiui-theme')).toBe('dark')
})
it('toggleTheme flips between dark and light', () => {
const { toggleTheme, currentTheme } = useTheme()
expect(currentTheme.value).toBe('dark')
toggleTheme()
expect(currentTheme.value).toBe('light')
toggleTheme()
expect(currentTheme.value).toBe('dark')
})
it('initTheme restores saved preference from localStorage', () => {
localStorage.setItem('aiui-theme', 'light')
const { initTheme, currentTheme } = useTheme()
initTheme()
expect(currentTheme.value).toBe('light')
})
it('initTheme falls back to prefers-color-scheme when no saved preference', () => {
localStorage.clear()
const matchMediaSpy = vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: false,
} as MediaQueryList)
const { initTheme, currentTheme } = useTheme()
initTheme()
expect(matchMediaSpy).toHaveBeenCalledWith('(prefers-color-scheme: dark)')
expect(currentTheme.value).toBe('light')
})
it('initTheme uses dark when system prefers dark', () => {
localStorage.clear()
vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,
} as MediaQueryList)
const { initTheme, currentTheme } = useTheme()
initTheme()
expect(currentTheme.value).toBe('dark')
})
it('shares state across multiple useTheme calls', () => {
const theme1 = useTheme()
const theme2 = useTheme()
theme1.setTheme('light')
expect(theme2.currentTheme.value).toBe('light')
expect(theme2.isDark.value).toBe(false)
})
})
+372
View File
@@ -0,0 +1,372 @@
export interface AppEntry {
id: string
name: string
description: string
longDescription: string
category: 'nostr-client' | 'lightning-wallet' | 'bitcoin-wallet' | 'privacy' | 'node' | 'dev-tool' | 'relay'
platforms: ('ios' | 'android' | 'web' | 'desktop' | 'cli' | 'nodeos')[]
url: string
icon?: string
keywords: string[]
howTo?: string[]
relatedApps?: string[]
}
export const APP_DATABASE: AppEntry[] = [
// ─── Nostr Clients ─────────────────────────────────────────────
{
id: 'damus',
name: 'Damus',
description: 'Native iOS Nostr client with Lightning zaps',
longDescription: 'Damus is the premier Nostr client for iOS. It provides a Twitter-like experience on the Nostr protocol with native Lightning Network integration for zaps (tips). Features include relay management, DMs, profile customization, and a smooth native UI.',
category: 'nostr-client',
platforms: ['ios'],
url: 'https://damus.io',
keywords: ['damus', 'damus.io', 'damus app'],
howTo: [
'Download Damus from the App Store',
'Create a new Nostr identity or import your nsec key',
'Add relays (defaults are provided)',
'Follow people by their npub or NIP-05 address',
'Connect a Lightning wallet for zaps',
],
relatedApps: ['primal', 'amethyst', 'snort'],
},
{
id: 'primal',
name: 'Primal',
description: 'Fast Nostr client with built-in wallet and caching',
longDescription: 'Primal is a high-performance Nostr client available on iOS, Android, and web. It features a built-in Lightning wallet (via Primal Wallet), advanced search and discovery through its caching layer, and a polished social media experience. The caching infrastructure makes it one of the fastest Nostr clients.',
category: 'nostr-client',
platforms: ['ios', 'android', 'web'],
url: 'https://primal.net',
keywords: ['primal', 'primal.net', 'primal app', 'primal wallet'],
howTo: [
'Visit primal.net or download the mobile app',
'Create a new account or log in with your nsec/extension',
'Set up your Primal Wallet for sending and receiving zaps',
'Explore trending content and follow accounts',
],
relatedApps: ['damus', 'snort', 'alby'],
},
{
id: 'snort',
name: 'Snort',
description: 'Clean web-based Nostr client',
longDescription: 'Snort is a web-based Nostr client with a clean, minimal interface. It supports NIP-07 browser extensions for key management, Lightning zaps, image/video uploads, and relay management. Great for users who prefer a browser-based experience.',
category: 'nostr-client',
platforms: ['web'],
url: 'https://snort.social',
keywords: ['snort', 'snort.social'],
howTo: [
'Visit snort.social in your browser',
'Install a NIP-07 extension (like nos2x or Alby) for key management',
'Create or import your Nostr identity',
'Configure relays and start posting',
],
relatedApps: ['primal', 'damus', 'alby'],
},
{
id: 'amethyst',
name: 'Amethyst',
description: 'Feature-rich Android Nostr client',
longDescription: 'Amethyst is the most popular Nostr client for Android. It supports a wide range of NIPs including long-form content, communities, live streams, and marketplace features. Deep Lightning integration for zaps and a highly customizable interface.',
category: 'nostr-client',
platforms: ['android'],
url: 'https://github.com/vitorpamplona/amethyst',
keywords: ['amethyst', 'amethyst nostr', 'amethyst app'],
howTo: [
'Install from Google Play Store or F-Droid',
'Create a new keypair or import existing nsec',
'Configure your relay list',
'Connect a Lightning wallet for zaps',
],
relatedApps: ['damus', 'primal', 'snort'],
},
{
id: 'coracle',
name: 'Coracle',
description: 'Relay-focused web Nostr client',
longDescription: 'Coracle is a web-based Nostr client that puts relay management front and center. It features excellent relay discovery, community-based feeds, and a focus on the social graph. Built with privacy and decentralization principles in mind.',
category: 'nostr-client',
platforms: ['web'],
url: 'https://coracle.social',
keywords: ['coracle', 'coracle.social'],
relatedApps: ['snort', 'nostrudel'],
},
{
id: 'iris',
name: 'Iris',
description: 'Nostr client with built-in key management',
longDescription: 'Iris is a Nostr client that focuses on ease of use with built-in key management. Available on web and as a desktop app. Features include DMs, profile management, and a clean interface.',
category: 'nostr-client',
platforms: ['web', 'desktop'],
url: 'https://iris.to',
keywords: ['iris', 'iris.to', 'iris nostr'],
relatedApps: ['snort', 'primal'],
},
{
id: 'nostrudel',
name: 'noStrudel',
description: 'Power-user Nostr web client',
longDescription: 'noStrudel is a feature-packed web client for Nostr power users. It supports a wide range of NIPs, advanced relay management, DVMs (Data Vending Machines), and experimental Nostr features. Great for developers and advanced users.',
category: 'nostr-client',
platforms: ['web'],
url: 'https://nostrudel.ninja',
keywords: ['nostrudel', 'nostrudel.ninja', 'no strudel'],
relatedApps: ['coracle', 'snort'],
},
// ─── Lightning Wallets ──────────────────────────────────────────
{
id: 'phoenix',
name: 'Phoenix',
description: 'Non-custodial Lightning wallet by ACINQ',
longDescription: 'Phoenix is a self-custodial Lightning wallet built by ACINQ (the team behind Eclair). It automatically manages channels and liquidity, making Lightning payments as simple as on-chain transactions. No channel management needed — just send and receive.',
category: 'lightning-wallet',
platforms: ['ios', 'android'],
url: 'https://phoenix.acinq.co',
keywords: ['phoenix', 'phoenix wallet', 'acinq', 'phoenix.acinq.co'],
howTo: [
'Download Phoenix from App Store or Play Store',
'Back up your 12-word seed phrase securely',
'Receive your first payment — a channel opens automatically',
'Use for Lightning payments, zaps, and daily spending',
],
relatedApps: ['breez', 'zeus', 'mutiny'],
},
{
id: 'breez',
name: 'Breez',
description: 'Non-custodial Lightning wallet with POS features',
longDescription: 'Breez is a non-custodial Lightning wallet that doubles as a point-of-sale system for merchants. Features include a built-in podcast player with streaming sats, fiat on-ramps, and seamless channel management via the Breez SDK.',
category: 'lightning-wallet',
platforms: ['ios', 'android'],
url: 'https://breez.technology',
keywords: ['breez', 'breez wallet', 'breez.technology', 'breez sdk'],
relatedApps: ['phoenix', 'zeus', 'mutiny'],
},
{
id: 'zeus',
name: 'Zeus',
description: 'Lightning node management and wallet',
longDescription: 'Zeus is a mobile Lightning wallet that can connect to your own Lightning node (LND, Core Lightning, or Eclair) or run an embedded node. Full control over channels, routing, and node management from your phone.',
category: 'lightning-wallet',
platforms: ['ios', 'android'],
url: 'https://zeusln.com',
keywords: ['zeus', 'zeus wallet', 'zeusln', 'zeus lightning', 'zeusln.com'],
relatedApps: ['phoenix', 'breez'],
},
{
id: 'alby',
name: 'Alby',
description: 'Lightning browser extension and NIP-07 signer',
longDescription: 'Alby is a browser extension that brings Lightning payments and Nostr key management to every website. It acts as a NIP-07 signer for Nostr clients and enables one-click Lightning payments across the web. Also offers Alby Hub for self-custodial Lightning.',
category: 'lightning-wallet',
platforms: ['web', 'desktop'],
url: 'https://getalby.com',
keywords: ['alby', 'getalby', 'alby extension', 'alby hub', 'getalby.com', 'nip-07'],
howTo: [
'Install the Alby browser extension',
'Create a new Lightning wallet or connect existing one',
'Use Alby to sign in to Nostr web clients',
'Send zaps and Lightning payments from any website',
],
relatedApps: ['snort', 'phoenix', 'primal'],
},
{
id: 'mutiny',
name: 'Mutiny Wallet',
description: 'Self-custodial Lightning wallet in the browser',
longDescription: 'Mutiny is a self-custodial Bitcoin and Lightning wallet that runs entirely in your web browser using WebAssembly. Features Nostr integration, fedimint support, and LSP-managed channels. Privacy-focused with optional Tor support.',
category: 'lightning-wallet',
platforms: ['web', 'ios', 'android'],
url: 'https://mutinywallet.com',
keywords: ['mutiny', 'mutiny wallet', 'mutinywallet.com'],
relatedApps: ['phoenix', 'breez'],
},
{
id: 'wallet-of-satoshi',
name: 'Wallet of Satoshi',
description: 'Simple custodial Lightning wallet',
longDescription: 'Wallet of Satoshi is the easiest way to get started with Lightning. As a custodial wallet, it requires no channel management — just install and start sending/receiving. Great for beginners, though advanced users may prefer self-custodial options.',
category: 'lightning-wallet',
platforms: ['ios', 'android'],
url: 'https://www.walletofsatoshi.com',
keywords: ['wallet of satoshi', 'walletofsatoshi', 'wos'],
relatedApps: ['phoenix', 'breez'],
},
// ─── Bitcoin Wallets ────────────────────────────────────────────
{
id: 'sparrow',
name: 'Sparrow Wallet',
description: 'Full-featured Bitcoin desktop wallet',
longDescription: 'Sparrow is the gold standard for Bitcoin desktop wallets. It supports hardware wallets, multisig, coin control, PSBT, and connects to your own node via Electrum. Excellent for privacy-conscious users with features like PayJoin and whirlpool integration.',
category: 'bitcoin-wallet',
platforms: ['desktop'],
url: 'https://sparrowwallet.com',
keywords: ['sparrow', 'sparrow wallet', 'sparrowwallet.com'],
howTo: [
'Download from sparrowwallet.com (verify GPG signature)',
'Connect to your own Electrum server or use a public one',
'Create a new wallet or import from hardware wallet',
'Enable coin control for better privacy',
],
relatedApps: ['bluewallet', 'nunchuk', 'coldcard'],
},
{
id: 'bluewallet',
name: 'BlueWallet',
description: 'Mobile Bitcoin and Lightning wallet',
longDescription: 'BlueWallet is a popular open-source Bitcoin wallet for iOS and Android. Supports on-chain and Lightning (via LNDHub), multisig vaults, watch-only wallets, and coin control. Clean interface suitable for both beginners and advanced users.',
category: 'bitcoin-wallet',
platforms: ['ios', 'android', 'desktop'],
url: 'https://bluewallet.io',
keywords: ['bluewallet', 'blue wallet', 'bluewallet.io'],
relatedApps: ['sparrow', 'phoenix'],
},
{
id: 'nunchuk',
name: 'Nunchuk',
description: 'Collaborative multisig Bitcoin wallet',
longDescription: 'Nunchuk specializes in collaborative multisig for Bitcoin self-custody. Features include assisted multisig with hardware wallets, inheritance planning, spending policies, and a clean mobile/desktop experience. Ideal for securing larger amounts.',
category: 'bitcoin-wallet',
platforms: ['ios', 'android', 'desktop'],
url: 'https://nunchuk.io',
keywords: ['nunchuk', 'nunchuk.io', 'nunchuk wallet'],
relatedApps: ['sparrow', 'coldcard'],
},
{
id: 'coldcard',
name: 'Coldcard',
description: 'Air-gapped Bitcoin hardware signer',
longDescription: 'Coldcard is a Bitcoin-only hardware signing device focused on security. Features air-gapped operation (via SD card or NFC), duress PINs, dice roll entropy, and PSBT support. The gold standard for cold storage security.',
category: 'bitcoin-wallet',
platforms: ['cli'],
url: 'https://coldcard.com',
keywords: ['coldcard', 'coldcard.com', 'cold card'],
relatedApps: ['sparrow', 'nunchuk'],
},
// ─── Privacy Tools ──────────────────────────────────────────────
{
id: 'simplex-chat',
name: 'SimpleX Chat',
description: 'Private messenger with no user IDs',
longDescription: 'SimpleX Chat is the only messenger that has no user identifiers — not even random numbers. It uses temporary anonymous pairwise addresses for each contact, making metadata analysis extremely difficult. Supports groups, voice, video, and file sharing.',
category: 'privacy',
platforms: ['ios', 'android', 'desktop', 'cli'],
url: 'https://simplex.chat',
keywords: ['simplex', 'simplex chat', 'simplex.chat'],
relatedApps: ['signal'],
},
{
id: 'signal',
name: 'Signal',
description: 'End-to-end encrypted messaging',
longDescription: 'Signal is the industry standard for encrypted messaging. Uses the Signal Protocol for E2E encryption of messages, calls, and video. Open source, no ads, no tracking. Requires a phone number for registration.',
category: 'privacy',
platforms: ['ios', 'android', 'desktop'],
url: 'https://signal.org',
keywords: ['signal', 'signal.org', 'signal messenger', 'signal app'],
relatedApps: ['simplex-chat'],
},
{
id: 'mullvad',
name: 'Mullvad VPN',
description: 'Privacy-focused VPN accepting Bitcoin',
longDescription: 'Mullvad is a VPN service that prioritizes privacy. No email or personal info needed to sign up — just a generated account number. Accepts Bitcoin and cash payments. Open-source clients, WireGuard support, and a strict no-logging policy.',
category: 'privacy',
platforms: ['ios', 'android', 'desktop', 'cli'],
url: 'https://mullvad.net',
keywords: ['mullvad', 'mullvad vpn', 'mullvad.net'],
},
// ─── Node Software ──────────────────────────────────────────────
{
id: 'start9',
name: 'Start9',
description: 'Sovereign computing platform for self-hosting',
longDescription: 'Start9 (formerly Embassy) is a Linux-based operating system for running a personal server. Self-host Bitcoin Core, Lightning, Nostr relays, and 200+ other services with a simple web UI. True digital sovereignty without command-line knowledge.',
category: 'node',
platforms: ['nodeos', 'desktop'],
url: 'https://start9.com',
keywords: ['start9', 'start9.com', 'embassy', 'startos'],
howTo: [
'Purchase a Start9 server or install StartOS on your own hardware',
'Access the web dashboard from your local network',
'Install services: Bitcoin Core, LND, Nostr relay, etc.',
'Configure Tor for remote access',
],
relatedApps: ['umbrel', 'raspiblitz'],
},
{
id: 'umbrel',
name: 'Umbrel',
description: 'Personal home server OS with app store',
longDescription: 'Umbrel is a beautiful OS for running a personal server at home. One-click install for Bitcoin Core, Lightning, Nostr relays, and hundreds of self-hosted apps. Runs on Raspberry Pi or any x86 hardware.',
category: 'node',
platforms: ['nodeos', 'desktop'],
url: 'https://umbrel.com',
keywords: ['umbrel', 'umbrel.com', 'umbrel os'],
relatedApps: ['start9', 'raspiblitz'],
},
{
id: 'raspiblitz',
name: 'RaspiBlitz',
description: 'DIY Bitcoin/Lightning node for Raspberry Pi',
longDescription: 'RaspiBlitz is a do-it-yourself Bitcoin and Lightning Network node running on a Raspberry Pi. Features a touchscreen LCD, automated setup scripts, and a focus on education. Great for learning how nodes work hands-on.',
category: 'node',
platforms: ['nodeos'],
url: 'https://raspiblitz.org',
keywords: ['raspiblitz', 'raspi blitz', 'raspiblitz.org'],
relatedApps: ['start9', 'umbrel', 'mynode'],
},
{
id: 'mynode',
name: 'myNode',
description: 'Easy Bitcoin and Lightning node',
longDescription: 'myNode provides a simple way to run a Bitcoin and Lightning node. Premium and community editions available. Includes Bitcoin Core, LND, Electrum Server, BTC Pay Server, and other essential services.',
category: 'node',
platforms: ['nodeos'],
url: 'https://mynodebtc.com',
keywords: ['mynode', 'mynodebtc', 'my node'],
relatedApps: ['start9', 'umbrel', 'raspiblitz'],
},
// ─── Dev Tools ──────────────────────────────────────────────────
{
id: 'ndk',
name: 'NDK',
description: 'Nostr Development Kit for building apps',
longDescription: 'NDK (Nostr Development Kit) is a JavaScript/TypeScript library for building Nostr applications. It handles relay connections, event signing, caching, and subscription management. The most popular framework for Nostr web development.',
category: 'dev-tool',
platforms: ['web', 'desktop', 'cli'],
url: 'https://github.com/nostr-dev-kit/ndk',
keywords: ['ndk', 'nostr development kit', 'nostr-dev-kit'],
relatedApps: ['nostr-tools', 'nak'],
},
{
id: 'nostr-tools',
name: 'nostr-tools',
description: 'Low-level Nostr protocol utilities',
longDescription: 'nostr-tools is a low-level JavaScript library for working with the Nostr protocol. Provides event creation, signing, relay communication, NIP implementations, and key management. Foundation library used by many Nostr clients.',
category: 'dev-tool',
platforms: ['web', 'desktop', 'cli'],
url: 'https://github.com/nbd-wtf/nostr-tools',
keywords: ['nostr-tools', 'nostr tools'],
relatedApps: ['ndk', 'nak'],
},
{
id: 'nak',
name: 'Nak',
description: 'Nostr CLI tool for power users',
longDescription: 'Nak is a command-line tool for interacting with Nostr relays. Useful for debugging, testing, and scripting. Can publish events, query relays, decode/encode Nostr identifiers, and manage keys from the terminal.',
category: 'dev-tool',
platforms: ['cli'],
url: 'https://github.com/fiatjaf/nak',
keywords: ['nak', 'nak cli', 'nak nostr'],
relatedApps: ['ndk', 'nostr-tools'],
},
]
+1
View File
@@ -569,6 +569,7 @@ const TAB_LABELS: Record<ContentTab, string> = {
magazine: 'Brief',
code: 'Code',
'design-system': 'Design',
app: 'Apps',
nostr: 'Nostr',
favorites: 'Favorites',
discover: 'Discover',
+7
View File
@@ -86,6 +86,7 @@ export const useChatStore = defineStore('chat', () => {
const webSearchEnabled = ref(localStorage.getItem('aiui-web-search') !== 'false')
const chatCollapsed = ref(localStorage.getItem('aiui-chat-collapsed') !== 'false')
const showHistory = ref(false)
// Load chats: try IndexedDB first, fall back to dev-chats middleware
async function loadChats() {
@@ -240,6 +241,10 @@ export const useChatStore = defineStore('chat', () => {
chatCollapsed.value = !chatCollapsed.value
}
function toggleHistory() {
showHistory.value = !showHistory.value
}
function setActiveConversation(id: string) {
if (conversations.value.has(id)) {
activeConversationId.value = id
@@ -354,6 +359,7 @@ export const useChatStore = defineStore('chat', () => {
panelSide,
webSearchEnabled,
chatCollapsed,
showHistory,
createConversation,
addMessage,
appendToLastMessage,
@@ -361,6 +367,7 @@ export const useChatStore = defineStore('chat', () => {
setMessageFeedback,
switchSide,
toggleChatCollapse,
toggleHistory,
setActiveConversation,
deleteConversation,
updateMessageContent,
+43 -32
View File
@@ -70,18 +70,52 @@ body {
}
.glass-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
height: 48px;
min-height: 48px;
padding-block: 0 !important;
padding-inline: 1.25rem;
line-height: 48px;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
border-radius: 0.75rem;
border: none;
color: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
}
.glass-button::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.glass-button:hover {
transform: translateY(-2px);
background: rgba(0, 0, 0, 0.35);
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.glass-button:hover::before {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
}
.glass-button-sm {
@@ -110,21 +144,6 @@ body {
border-radius: 1rem;
}
.gradient-button {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(0, 0, 0, 0.8) 100%);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.95);
transition: all 0.3s ease;
}
.gradient-button:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(0, 0, 0, 0.9) 100%);
border-color: rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
.gradient-border-container {
position: relative;
border-radius: 1.5rem;
@@ -369,28 +388,20 @@ body {
.light .glass-button {
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
border: 1px solid #0a0a0a;
border: none;
color: #fafafa;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.light .glass-button::before {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), transparent);
}
.light .glass-button:hover {
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.light .gradient-button {
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
border: 1px solid #0a0a0a;
color: #fafafa;
}
.light .gradient-button:hover {
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
border-color: #1a1a1a;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.light .gradient-card {
background: linear-gradient(135deg, #fefdf9 0%, #faf9f6 100%);
border: 1px solid #e8e8e8;
+123 -54
View File
@@ -11,15 +11,23 @@ export interface WebSearchResult {
engine?: string
}
const FALLBACK_INSTANCES = [
// SearXNG public instances — rotated on each request to spread load
const SEARXNG_INSTANCES = [
'https://searx.tiekoetter.com',
'https://search.bus-hit.me',
'https://paulgo.io',
'https://search.sapti.me',
'https://search.ononoki.org',
'https://priv.au',
'https://opnxng.com',
'https://etsi.me',
]
// Rotate starting instance per-request to avoid hammering a single one
let instanceRotation = 0
async function fetchFromSearXNG(
searchUrl: string,
q: string,
): Promise<{ results?: { title?: string; url?: string; content?: string; img_src?: string; thumbnail?: string; engine?: string }[] } | null> {
try {
const searchRes = await fetch(searchUrl, {
@@ -27,13 +35,52 @@ async function fetchFromSearXNG(
signal: AbortSignal.timeout(6000),
})
if (!searchRes.ok) return null
return (await searchRes.json()) as { results?: { title?: string; url?: string; content?: string; img_src?: string; thumbnail?: string; engine?: string }[] }
const text = await searchRes.text()
// Guard against HTML responses from captcha/blocking pages
if (text.startsWith('<') || text.startsWith('<!')) return null
return JSON.parse(text)
} catch {
return null
}
}
function createWebSearchMiddleware(searxUrl: string | undefined) {
/** Brave Search API — free tier (2000 queries/month). Set BRAVE_SEARCH_API_KEY in .env.local */
async function fetchFromBrave(
q: string,
apiKey: string,
): Promise<WebSearchResult[]> {
try {
const res = await fetch(
`https://api.search.brave.com/res/v1/web/search?${new URLSearchParams({ q, count: '6' })}`,
{
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': apiKey,
},
signal: AbortSignal.timeout(8000),
},
)
if (!res.ok) return []
const data = (await res.json()) as {
web?: { results?: { title?: string; url?: string; description?: string; thumbnail?: { src?: string } }[] }
}
return (data.web?.results ?? [])
.filter((r) => r.title && r.url)
.slice(0, 6)
.map((r) => ({
title: r.title ?? '',
url: r.url ?? '',
content: r.description ?? undefined,
imgSrc: r.thumbnail?.src ?? undefined,
engine: 'brave',
}))
} catch {
return []
}
}
function createWebSearchMiddleware(searxUrl: string | undefined, braveApiKey: string | undefined) {
return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
if (req.method !== 'GET') return next()
const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
@@ -44,55 +91,48 @@ function createWebSearchMiddleware(searxUrl: string | undefined) {
return
}
const instances = searxUrl ? [searxUrl] : FALLBACK_INSTANCES
const sendResults = (results: WebSearchResult[]) => {
res.setHeader('Content-Type', 'application/json')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Cache-Control', 'public, max-age=300')
res.end(JSON.stringify({ results }))
}
// 1. Brave Search API (most reliable if configured)
if (braveApiKey) {
console.log('[web-search]', q.slice(0, 40), '→ Brave API')
const braveResults = await fetchFromBrave(q, braveApiKey)
if (braveResults.length > 0) {
console.log('[web-search]', braveResults.length, 'results from Brave')
sendResults(braveResults)
return
}
console.warn('[web-search] Brave API returned no results, falling through')
}
// 2. SearXNG instances (rotate to spread load)
const instances = searxUrl ? [searxUrl] : SEARXNG_INSTANCES
const startIdx = instanceRotation % instances.length
instanceRotation++
let data: { results?: { title?: string; url?: string; content?: string; img_src?: string; thumbnail?: string; engine?: string }[] } | null = null
let lastError = ''
for (const baseUrl of instances) {
const normalized = baseUrl.replace(/\/$/, '')
const searchUrl = `${normalized}/search?${new URLSearchParams({ q, format: 'json', pageno: '1' })}`
console.log('[web-search]', q.slice(0, 40), '→', normalized)
data = await fetchFromSearXNG(searchUrl, q)
for (let i = 0; i < instances.length; i++) {
const baseUrl = instances[(startIdx + i) % instances.length].replace(/\/$/, '')
const searchUrl = `${baseUrl}/search?${new URLSearchParams({ q, format: 'json', pageno: '1' })}`
console.log('[web-search]', q.slice(0, 40), '→', baseUrl)
data = await fetchFromSearXNG(searchUrl)
if (data?.results && data.results.length > 0) {
console.log('[web-search]', data.results.length, 'results from', normalized)
console.log('[web-search]', data.results.length, 'results from', baseUrl)
break
}
lastError = data ? 'no results' : 'request failed'
console.warn('[web-search]', normalized, lastError, '— trying next')
console.warn('[web-search]', baseUrl, lastError, '— trying next')
data = null
}
if (!data) {
console.warn('[web-search] SearXNG failed, trying DuckDuckGo fallback')
try {
const ddg = await searchDuckDuckGo(q)
if (ddg?.results?.length) {
const results: WebSearchResult[] = ddg.results
.filter((r: { title?: string; url?: string }) => r.title && r.url)
.slice(0, 6)
.map((r: { title: string; url: string; description?: string; icon?: string }) => ({
title: r.title,
url: r.url,
content: r.description ?? undefined,
imgSrc: r.icon ?? undefined,
}))
console.log('[web-search] DuckDuckGo fallback:', results.length, 'results')
res.setHeader('Content-Type', 'application/json')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Cache-Control', 'public, max-age=300')
res.end(JSON.stringify({ results }))
return
}
} catch (ddgErr) {
console.warn('[web-search] DuckDuckGo fallback failed:', ddgErr)
}
console.error('[web-search] All search backends failed:', lastError)
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Web search unavailable. Try SEARXNG_URL in .env.local.' }))
return
}
try {
if (data?.results) {
const results: WebSearchResult[] = (data.results ?? [])
.filter((r) => r.title && r.url)
.slice(0, 6)
@@ -103,33 +143,62 @@ function createWebSearchMiddleware(searxUrl: string | undefined) {
imgSrc: r.img_src || r.thumbnail || undefined,
engine: r.engine ?? undefined,
}))
res.setHeader('Content-Type', 'application/json')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Cache-Control', 'public, max-age=300')
res.end(JSON.stringify({ results }))
} catch (err) {
console.error('[web-search]', err)
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
sendResults(results)
return
}
// 3. DuckDuckGo fallback
console.warn('[web-search] SearXNG failed, trying DuckDuckGo fallback')
try {
const ddg = await searchDuckDuckGo(q)
if (ddg?.results?.length) {
const results: WebSearchResult[] = ddg.results
.filter((r: { title?: string; url?: string }) => r.title && r.url)
.slice(0, 6)
.map((r: { title: string; url: string; description?: string; icon?: string }) => ({
title: r.title,
url: r.url,
content: r.description ?? undefined,
imgSrc: r.icon ?? undefined,
}))
console.log('[web-search] DuckDuckGo fallback:', results.length, 'results')
sendResults(results)
return
}
} catch (ddgErr) {
console.warn('[web-search] DuckDuckGo fallback failed:', ddgErr)
}
console.error('[web-search] All search backends failed:', lastError)
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Web search unavailable. Set BRAVE_SEARCH_API_KEY or SEARXNG_URL in .env.local.' }))
}
}
export function webSearchPlugin(): Plugin {
let searxUrl: string | undefined
let braveApiKey: string | undefined
return {
name: 'aiui-web-search',
configResolved(config) {
const env = loadEnv(config.mode, process.cwd(), '')
searxUrl = env.SEARXNG_URL ?? env.VITE_SEARXNG_URL
braveApiKey = env.BRAVE_SEARCH_API_KEY ?? env.VITE_BRAVE_SEARCH_API_KEY
if (braveApiKey) {
console.log('[web-search] Brave Search API configured')
} else if (searxUrl) {
console.log('[web-search] SearXNG:', searxUrl)
} else {
console.log('[web-search] No search API key configured — using public SearXNG instances (unreliable)')
console.log('[web-search] For reliable search, set BRAVE_SEARCH_API_KEY in .env.local (free: https://brave.com/search/api/)')
}
},
configureServer(server) {
server.middlewares.use('/api/web-search', createWebSearchMiddleware(searxUrl))
server.middlewares.use('/api/web-search', createWebSearchMiddleware(searxUrl, braveApiKey))
},
configurePreviewServer(server) {
server.middlewares.use('/api/web-search', createWebSearchMiddleware(searxUrl))
server.middlewares.use('/api/web-search', createWebSearchMiddleware(searxUrl, braveApiKey))
},
}
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@aiui/core': resolve(__dirname, '../core/src'),
},
},
test: {
globals: true,
environment: 'happy-dom',
exclude: ['e2e/**', 'node_modules/**'],
},
})
+18
View File
@@ -0,0 +1,18 @@
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.ts'],
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
{
ignores: ['dist/', 'node_modules/'],
},
)
+7 -5
View File
@@ -26,12 +26,14 @@
"vue": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitejs/plugin-vue": "^6.0.4",
"vite": "^7.3.1",
"vue": "^3.5.29",
"vue-tsc": "^3.2.5",
"vitest": "^4.0.18",
"eslint": "^10.0.2",
"typescript": "~5.8.0"
"typescript": "~5.8.0",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1",
"vitest": "^4.0.18",
"vue": "^3.5.29",
"vue-tsc": "^3.2.5"
}
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest'
import {
registerPlugin,
unregisterPlugin,
getPlugin,
getPluginsByType,
registerRenderer,
getRendererForContentType,
getAllRenderers,
} from './plugins/registry'
import type { AIUIPlugin } from './types/plugin'
import type { RendererDefinition } from './types/renderer'
function createMockPlugin(overrides: Partial<AIUIPlugin> = {}): AIUIPlugin {
return {
id: 'test-plugin',
name: 'Test Plugin',
version: '1.0.0',
type: 'ai-provider',
async init() {},
async destroy() {},
async isAvailable() { return true },
...overrides,
}
}
describe('core exports', () => {
it('exports plugin registry functions', () => {
expect(registerPlugin).toBeTypeOf('function')
expect(unregisterPlugin).toBeTypeOf('function')
expect(getPlugin).toBeTypeOf('function')
expect(getPluginsByType).toBeTypeOf('function')
})
it('exports renderer registry functions', () => {
expect(registerRenderer).toBeTypeOf('function')
expect(getRendererForContentType).toBeTypeOf('function')
expect(getAllRenderers).toBeTypeOf('function')
})
})
describe('plugin registry', () => {
it('registers and retrieves a plugin', () => {
const plugin = createMockPlugin({ id: 'reg-test' })
registerPlugin(plugin)
const retrieved = getPlugin('reg-test')
expect(retrieved).toBeDefined()
expect(retrieved?.id).toBe('reg-test')
unregisterPlugin('reg-test')
})
it('unregisters a plugin', () => {
const plugin = createMockPlugin({ id: 'unreg-test' })
registerPlugin(plugin)
unregisterPlugin('unreg-test')
expect(getPlugin('unreg-test')).toBeUndefined()
})
it('filters plugins by type', () => {
const p1 = createMockPlugin({ id: 'type-a', type: 'ai-provider' })
const p2 = createMockPlugin({ id: 'type-b', type: 'storage' })
registerPlugin(p1)
registerPlugin(p2)
const providers = getPluginsByType('ai-provider')
expect(providers.some(p => p.id === 'type-a')).toBe(true)
expect(providers.some(p => p.id === 'type-b')).toBe(false)
unregisterPlugin('type-a')
unregisterPlugin('type-b')
})
it('skips duplicate registration', () => {
const plugin = createMockPlugin({ id: 'dup-test' })
registerPlugin(plugin)
registerPlugin(plugin) // should warn but not throw
unregisterPlugin('dup-test')
})
})
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
test: {
globals: true,
},
})