diff --git a/.cursor/rules/02-tailwind-styling.mdc b/.cursor/rules/02-tailwind-styling.mdc index eeb2e0c8..49f0c285 100644 --- a/.cursor/rules/02-tailwind-styling.mdc +++ b/.cursor/rules/02-tailwind-styling.mdc @@ -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 diff --git a/.cursor/rules/03-design-system.mdc b/.cursor/rules/03-design-system.mdc index 38a10ef7..3a760b66 100644 --- a/.cursor/rules/03-design-system.mdc +++ b/.cursor/rules/03-design-system.mdc @@ -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 diff --git a/.cursor/rules/15-mobile-ux.mdc b/.cursor/rules/15-mobile-ux.mdc index c5f9db35..9cb736a6 100644 --- a/.cursor/rules/15-mobile-ux.mdc +++ b/.cursor/rules/15-mobile-ux.mdc @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 4bdef965..66d96c13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/PLAN2.md b/PLAN2.md index 6b86299f..76c3b583 100644 --- a/PLAN2.md +++ b/PLAN2.md @@ -3,7 +3,7 @@ ## Context & Philosophy This plan continues from M0–M7 (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 diff --git a/loop/loop.sh b/loop/loop.sh index 4f31c74c..7498dd3b 100755 --- a/loop/loop.sh +++ b/loop/loop.sh @@ -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 "" diff --git a/loop/prompt.md b/loop/prompt.md index 709d5779..afda55c3 100644 --- a/loop/prompt.md +++ b/loop/prompt.md @@ -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` diff --git a/package.json b/package.json index 7a5f9ac4..1c09db1e 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,11 @@ "pnpm": ">=10.0.0" }, "pnpm": { - "onlyBuiltDependencies": ["esbuild"] + "onlyBuiltDependencies": [ + "esbuild" + ] + }, + "dependencies": { + "pdfjs-dist": "^5.5.207" } } diff --git a/packages/app/dev-dist/sw.js b/packages/app/dev-dist/sw.js index c27d8884..a816850b 100644 --- a/packages/app/dev-dist/sw.js +++ b/packages/app/dev-dist/sw.js @@ -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'); })); diff --git a/packages/app/server/claude-proxy.ts b/packages/app/server/claude-proxy.ts index 556b033e..f611b6fa 100644 --- a/packages/app/server/claude-proxy.ts +++ b/packages/app/server/claude-proxy.ts @@ -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 = {} + 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 { + 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 { + // 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 { @@ -101,10 +220,12 @@ async function runSearchWeb(query: string): Promise { } async function streamViaAnthropicApi( + credential: string, model: string, system: string | undefined, messages: { role: string; content: string }[], res: import('http').ServerResponse, + webSearch: boolean, ): Promise { 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 = { '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() }) diff --git a/packages/app/src/components/chat/ChatHeader.vue b/packages/app/src/components/chat/ChatHeader.vue index 0a00508f..871e46bd 100644 --- a/packages/app/src/components/chat/ChatHeader.vue +++ b/packages/app/src/components/chat/ChatHeader.vue @@ -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" > @@ -28,12 +28,26 @@ + + + + - - -