feat(content): comprehensive extraction hardening, code mode UI, overnight plan
- Relax isBookLikeResponse threshold (>=2 to >=1) - Widen book patterns: inline prose, verb-preceded, numbered bold, em-dash - Remove overly aggressive film/song gating on book extraction - Allow songs to coexist with film/book tags (explicit tags always returned) - Strengthen TV patterns: seasons, created by, standalone "tv" query match - Fix TV_EXT_RE to handle both Title|Year|Creator and Title|Creator|Year - Widen place patterns: type word search in descriptions, bold fallback - Add "pizza" to isPlaceQuery and preferredFirstTab - Add Code tab: isCodeQuery, isCodeLikeResponse, extractCodeBlocks, wiring - Fix image threshold: single image with meaningful alt text shown - Refactor filterTabsByContext: specialized paths now append remaining content - Add code mode UI: orange input styling, design system selection, file selection - DesignSystemGrid: selection toggle only on checkmark, card click opens detail - Add 64-test extraction quality test suite - Update overnight plan.md and prompt.md for hardening run Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
75e9274582
commit
f0eb4383bd
+19
-165
@@ -1,184 +1,38 @@
|
||||
# AIUI Plan 2 — Automated Loop Task List
|
||||
# Overnight Plan — Content Surfacing Hardening & Feature Completion
|
||||
|
||||
> **Format**: `- [ ]` = pending, `- [x]` = done.
|
||||
> Make at least 30 attempts on any difficult task before moving on. Loop reads this file. Specs are in `PLAN2.md`. Design rules are in `loop/prompt.md`.
|
||||
> Make at least 30 attempts on any difficult task before moving on. Loop reads this file.
|
||||
|
||||
## M8: Chat UX Polish
|
||||
## Phase 1: Critical ContentPanel Wiring Gaps
|
||||
|
||||
- [x] **M8.1** — Message editing & regeneration (pencil icon on hover, re-send clears subsequent messages)
|
||||
- [x] **M8.2** — Conversation branching (fork from any message, branch switcher pill, each branch stored in IDB)
|
||||
- [x] **M8.3** — Reply-to threading (quoted excerpt in input, thread line visual, prepend `> quote` to message)
|
||||
- [x] **M8.4** — Conversation search (`Cmd+F` glass panel, real-time highlight, up/down to jump between matches)
|
||||
- [x] **M8.5** — Auto-title generation (background AI call after first exchange, silently replaces "New Chat")
|
||||
- [x] **M8.6** — Context window visualiser (slim progress bar, token estimate, orange → red > 80%)
|
||||
- [x] **M8.7** — Conversation export (Markdown / JSON / plain text, File System Access API + `<a download>` fallback)
|
||||
- [x] **M8.8** — Import conversations (drag-and-drop JSON, AIUI format + Claude.ai export format, merge into IDB)
|
||||
- [x] **M8.9** — Long-press / right-click context menus (messages + content cards, `ContextMenu.vue` glass-card)
|
||||
- [x] **M8.10** — Scroll position memory (restore per conversation ID, works with virtual scroller)
|
||||
- [x] **TEST:M8** — Run `pnpm test` and `pnpm typecheck && pnpm lint`. Fix all failures before proceeding.
|
||||
- [ ] **T1** — Wire ImageGrid + ImageDetail into ContentPanel.vue. Import components, destructure `panelImages`, `selectedImage`, `openImageDetail`, `closeImageDetail` from `useContentPanel()`. Add ImageGrid render block following BookGrid/TVSeriesGrid pattern. Add ImageDetail overlay following BookDetail pattern. Add `selectedImage` to `hasDetailOpen` computed. Reference ContentGridView.vue for how ImageGrid is already used there.
|
||||
|
||||
## M9: AI Experience
|
||||
- [ ] **T2** — Wire PlaceGrid + PlaceDetail into ContentPanel.vue. Import components, destructure `panelPlaces`, `selectedPlace`, `openPlaceDetail`, `closePlaceDetail` from `useContentPanel()`. Add PlaceGrid render block + PlaceDetail overlay. Add `selectedPlace` to `hasDetailOpen` computed. Follow same patterns as T1.
|
||||
|
||||
- [x] **M9.1** — Multi-model comparison mode (split-screen same prompt → 2 models, swipeable on mobile)
|
||||
- [x] **M9.2** — System prompt editor / personas (named personas, model preference, accent, per-conversation)
|
||||
- [x] **M9.3** — Prompt template library (`/` command palette, `{{variable}}` substitution, IDB-persisted)
|
||||
- [x] **M9.4** — Vision input (drag-and-drop image, base64 in message content, only when model supports vision)
|
||||
- [x] **M9.5** — Response feedback (thumbs up/down per message, stored in IDB, exported with conversations)
|
||||
- [x] **M9.6** — Token & cost estimator (badge per message, running total in context bar, model pricing table)
|
||||
- [x] **M9.7** — AI memory panel (pinned facts injected into every system prompt, encrypted IDB, max 20 items)
|
||||
- [x] **M9.8** — Model capabilities badge (Vision / Tools / Long context badges in model selector)
|
||||
- [x] **M9.9** — Temperature & params sliders (temperature, max tokens, top-P, persisted per conversation)
|
||||
- [x] **M9.10** — Stop sequence configuration (comma-separated stop sequences in advanced settings)
|
||||
- [x] **TEST:M9** — Run `pnpm test` and `pnpm typecheck && pnpm lint`. Fix all failures before proceeding.
|
||||
- [ ] **T3** — Verify code tab rendering in ContentPanel.vue. Ensure `panelCodeBlocks` is destructured and code content renders when the code tab is active. If missing, add a code block display component or inline rendering with syntax highlighting. The code tab should show extracted code blocks from AI responses.
|
||||
|
||||
## M10: Advanced Content Renderers
|
||||
- [ ] **TEST:P1** — Run `cd packages/app && npx vitest run` and `pnpm typecheck && pnpm lint`. Fix all failures before proceeding.
|
||||
|
||||
- [x] **M10.1** — Full article renderer (TOC sidebar, reading time, font-size control, print mode, markdown-it)
|
||||
- [x] **M10.2** — PDF viewer (`pdfjs-dist` lazy loaded, page nav, zoom, text selection, page 1 thumbnail preview)
|
||||
- [x] **M10.3** — Map renderer (Leaflet + OpenStreetMap lazy loaded, pins for all places, cluster > 10, fullscreen)
|
||||
- [x] **M10.4** — Recipe renderer (new `recipe` content type, ingredients checklist, numbered steps, scale slider)
|
||||
- [x] **M10.5** — Event renderer (new `event` content type, countdown, ICS download, Google Calendar URL)
|
||||
- [x] **M10.6** — Math renderer (KaTeX lazy loaded, `$...$` inline + `$$...$$` block, batch render on stream end)
|
||||
- [x] **M10.7** — Mermaid diagram renderer (lazy loaded, dark theme, flowchart/sequence/gantt, pan/zoom mobile)
|
||||
- [x] **M10.8** — Audio waveform player (WaveSurfer.js lazy loaded, orange waveform, click-to-seek)
|
||||
- [x] **M10.9** — Table renderer (sortable columns, row filter, CSV export, virtualise > 500 rows)
|
||||
- [x] **M10.10** — Timeline renderer (new `timeline` content type, vertical layout, animate in during streaming)
|
||||
- [x] **M10.11** — Code runner (HTML/CSS/JS sandboxed iframe, `sandbox="allow-scripts"`, output console)
|
||||
- [x] **M10.12** — Video renderer (native `<video>`, HLS.js lazy loaded, YouTube nocookie embed fallback)
|
||||
- [x] **TEST:M10** — Run `pnpm test` and `pnpm typecheck && pnpm lint`. Fix all failures before proceeding.
|
||||
## Phase 2: Content Extraction Hardening
|
||||
|
||||
## M11: Nostr Ecosystem
|
||||
- [ ] **T4** — Expand extraction quality test suite to 100+ tests. Add tests for: podcast extraction (library `[[podcast:p1]]` tags + `[[podcast_ext:]]` tags), app extraction (`extractApps`), film extraction (library `[[film:f1]]` tags + `[[film_ext:]]` tags), magazine section extraction, websites/news detection from markdown links and bare domain extraction, edge cases (empty strings, very long text, unicode characters, special chars in titles), mixed content with 3+ types in one response, `filterTabsByContext` with every specialized path (news+TV, nostr+apps, app+code, etc). Each test should use realistic AI response text patterns. File: `packages/app/src/__tests__/extractionQuality.test.ts`
|
||||
|
||||
- [x] **M11.1** — Publish Nostr notes (compose panel, sign via NIP-07, broadcast to relays, per-relay status)
|
||||
- [x] **M11.2** — Nostr DMs NIP-17 (encrypted DMs, inbox tab, contact list, store in IDB)
|
||||
- [x] **M11.3** — Relay management UI (add/remove relays, latency column, read/write toggle, import NIP-65)
|
||||
- [x] **M11.4** — Nostr profile editor (display name, bio, avatar, NIP-05, Lightning address, publish kind:0)
|
||||
- [x] **M11.5** — Zaps NIP-57 (⚡ button on notes/profiles, sat amount input, LNURL-pay flow, QR + deep-link)
|
||||
- [x] **M11.6** — NIP-05 verification badge (✓ badge, fetch from NIP-05 domain, cache 24h in IDB)
|
||||
- [x] **M11.7** — Nostr search NIP-50 (search input, send REQ with `search` to NIP-50 relays, filter by type)
|
||||
- [x] **M11.8** — Thread view (fetch root + replies via `#e` tag, render threaded tree, max 5 levels deep)
|
||||
- [x] **M11.9** — Nostr lists NIP-51 (follow/mute/pin/bookmark lists, kind:3/10000/10001/10003, add/remove)
|
||||
- [x] **M11.10** — Long-form content NIP-23 (kind:30023 articles, article renderer, discovery tab, share button)
|
||||
- [x] **TEST:M11** — Run `pnpm test` and `pnpm typecheck && pnpm lint`. Fix all failures before proceeding.
|
||||
- [ ] **T5** — Fix any extraction bugs found during T4 testing. Run the expanded test suite, fix all failures in `contentExtraction.ts` and/or `contentFiltering.ts`. Iterate until all tests pass. Document any patterns that needed fixing.
|
||||
|
||||
## M12: Bitcoin Ecosystem
|
||||
- [ ] **T6** — Verify filterTabsByContext prioritization is correct for all paths. The function was recently refactored to use a `prioritize()` helper that appends remaining content after priority tabs. Write specific tests ensuring: news queries show news/magazine first but include TV/books/etc if present, nostr queries show nostr first but include other content, app queries show apps first, preferred tab from `preferredFirstTab()` is always first in general case, no content type is ever silently dropped. File: `packages/app/src/__tests__/extractionQuality.test.ts`
|
||||
|
||||
- [x] **M12.1** — On-chain address display (bech32 + legacy detect, QR, mempool.space link, copy button)
|
||||
- [x] **M12.2** — Fedimint ecash display (detect token format, amount in sats, "Receive in Fedi" deep-link)
|
||||
- [x] **M12.3** — BOLT12 offers (detect `lno1...`, decoded amount, description, QR, "Pay with wallet" deep-link)
|
||||
- [x] **M12.4** — Nostr Wallet Connect NWC (NIP-47, paste connection string, check balance, pay with confirmation)
|
||||
- [x] **M12.5** — LNURL-auth login (generate LNURL-auth QR, scan with wallet, set persistent Lightning identity)
|
||||
- [x] **M12.6** — Live sat/fiat price (mempool.space `/api/v1/prices`, 60s refresh, `useBitcoinPrice` composable)
|
||||
- [x] **M12.7** — Mempool.space tx viewer (detect txid/block height, confirmations, fee rate, live WebSocket)
|
||||
- [x] **M12.8** — BOLT11 decoder card (amount, description, expiry countdown, red when < 5min, NWC pay button)
|
||||
- [x] **TEST:M12** — Run `pnpm test` and `pnpm typecheck && pnpm lint`. Fix all failures before proceeding.
|
||||
- [ ] **TEST:P2** — Run `cd packages/app && npx vitest run src/__tests__/extractionQuality.test.ts` — must have 100+ tests all passing. Then run `pnpm typecheck && pnpm lint`.
|
||||
|
||||
## M13: Content Discovery
|
||||
## Phase 3: Code Mode UX Completion
|
||||
|
||||
- [x] **M13.1** — "For You" feed (frequency map from favorites + history, local only, refreshes on app open)
|
||||
- [x] **M13.2** — Content tagging (user tags on any item, stored in IDB, filter grids by tag, tag cloud in favorites)
|
||||
- [x] **M13.3** — Smart playlists (recently played, most played, by genre, by decade — computed IDB views)
|
||||
- [x] **M13.4** — Similar content (background AI call per item, 3 suggestions, cached 7 days in IDB)
|
||||
- [x] **M13.5** — Recently viewed history (last 50 items any type, "Recent" tab in content panel, stored IDB)
|
||||
- [x] **M13.6** — Content collections (user-curated mixed-type lists, mosaic thumbnail grid, shareable NIP-51)
|
||||
- [x] **M13.7** — Trending in conversations (most-referenced items across 30 days, "referenced N times" badge)
|
||||
- [x] **M13.8** — Share content to Nostr (generate note with title + AI description + tag URL, sign via NIP-07)
|
||||
- [x] **TEST:M13** — Run `pnpm test` and `pnpm typecheck && pnpm lint`. Fix all failures before proceeding.
|
||||
- [ ] **T7** — Add Esc key to exit code mode on desktop. In ChatWindow.vue or ChatPage.vue, add a `keydown` event listener for Escape that calls `exitCodeMode()` from `useCodeContext` when code mode is active (`codeMode.value === true`). Only handle on desktop (not mobile). The listener should be added in `onMounted` and cleaned up in `onUnmounted`.
|
||||
|
||||
## M14: Plugin Marketplace
|
||||
- [ ] **T8** — Show selected project indicator near chat input when in code mode. When `codeMode` is true and a project is selected (`currentProject` from `useCodeContext`), display the project name as a small pill/badge near the chat input area. Use accent/orange styling consistent with the code mode input styling (bg-accent/15, text-accent). Show in ChatInput.vue or as a banner above it in ChatWindow.vue.
|
||||
|
||||
- [x] **M14.1** — Plugin discovery UI (fetch static registry JSON, list with name/type/author/version, install button)
|
||||
- [x] **M14.2** — Plugin settings panel (`PluginSettingsForm.vue` renders JSON Schema, stored encrypted IDB)
|
||||
- [x] **M14.3** — Plugin permissions UI (permissions dialog on install, grant/deny per capability, stored per plugin)
|
||||
- [x] **M14.4** — Plugin dev mode (`VITE_PLUGIN_DEV=true`, hot-reload from `src/plugins/dev/`, error inspector)
|
||||
- [x] **M14.5** — Built-in plugin: Wikipedia (`/wiki {query}`, Wikipedia REST API, returns `article` card)
|
||||
- [x] **M14.6** — Built-in plugin: OpenLibrary (`/book {query}`, openlibrary.org, returns `book_ext` cards)
|
||||
- [x] **M14.7** — Plugin import by URL (paste GitHub raw URL or IPFS CID, validate manifest, permissions dialog)
|
||||
- [x] **M14.8** — Plugin versioning & auto-update (check registry on start, badge when updates available, changelog)
|
||||
- [x] **TEST:M14** — Run `pnpm test` and `pnpm typecheck && pnpm lint`. Fix all failures before proceeding.
|
||||
- [ ] **T9** — Wire selected design tokens + files into AI context. When sending a message in code mode, include any selected design tokens and selected file contents as context. In `useAI.ts` `sendMessage()`, check if code mode is active, read `selectedDesignTokens` and `selectedFiles` from `useCodeContext()`, read file contents for selected files, and prepend this context to the system prompt or as a prefixed user message section. Keep context concise — include file paths and key content, not full files.
|
||||
|
||||
## M15: Settings & Personalisation
|
||||
- [ ] **TEST:P3** — Run `pnpm typecheck && pnpm lint`. Fix all failures before proceeding.
|
||||
|
||||
- [x] **M15.1** — Accent colour picker (colour wheel + presets, updates `--color-accent` CSS var, IDB-persisted)
|
||||
- [x] **M15.2** — Glass intensity slider (Subtle/Default/Strong presets, blur + opacity CSS vars, live preview)
|
||||
- [x] **M15.3** — Font size settings (Compact 13px / Default 15px / Large 17px, sets `--font-size-base`)
|
||||
- [x] **M15.4** — Content type visibility (toggle each of 11 renderers, hidden types still extract, just not shown)
|
||||
- [x] **M15.5** — Keyboard shortcut map (list all shortcuts, click to rebind, conflict detection, IDB-persisted)
|
||||
- [x] **M15.6** — Browser push notifications (opt-in, "Generation complete" while backgrounded, SW notification)
|
||||
- [x] **M15.7** — Auto-archive old conversations (7/30/90/never slider, "Archive" IDB store, unarchive per item)
|
||||
- [x] **M15.8** — Full data export (JSON archive of all IDB stores, optional encryption, GDPR compliant)
|
||||
- [x] **M15.9** — Data wipe (two-step confirm, clears IDB + SW cache + localStorage, checkbox for API key vault)
|
||||
- [x] **M15.10** — Default conversation settings (model, persona, web search, token counts — applies to new convos)
|
||||
- [x] **TEST:M15** — Run `pnpm test` and `pnpm typecheck && pnpm lint`. Fix all failures before proceeding.
|
||||
## Phase 4: Final Verification
|
||||
|
||||
## M16: Mobile UX Polish
|
||||
|
||||
- [x] **M16.1** — Bottom sheet component (`BottomSheet.vue`, gesture drag, 40%/80%/100% snap points, backdrop)
|
||||
- [x] **M16.2** — Swipe to navigate conversations (swipe left/right in chat, slide transition, 80px + 0.3 velocity)
|
||||
- [x] **M16.3** — Pull-to-refresh on content panels (glass spinner, re-fetch context, haptic on release)
|
||||
- [x] **M16.4** — Haptic feedback (`useHaptics()` composable, `navigator.vibrate()`, settings toggle to disable)
|
||||
- [x] **M16.5** — Web Share API (native `navigator.share()`, fallback glass share sheet, copy/Nostr options)
|
||||
- [x] **M16.6** — Pinch-to-zoom on images & maps (`usePinchZoom()` composable, 1×–4×, double-tap reset)
|
||||
- [x] **M16.7** — iOS PWA polish (apple-mobile-web-app meta tags, safe area insets, splash screens)
|
||||
- [x] **M16.8** — Long-press context menus mobile (500ms trigger, opens as BottomSheet, haptic on trigger)
|
||||
- [x] **M16.9** — Scroll position memory per route (session Map, works across tab switches and back navigation)
|
||||
- [x] **M16.10** — Landscape mode optimisation (50/50 split layout, minimal player bar, smooth rotate transition)
|
||||
- [x] **TEST:M16** — Run `pnpm test` and `pnpm typecheck && pnpm lint`. Fix all failures before proceeding.
|
||||
|
||||
## M17: Accessibility & Internationalisation
|
||||
|
||||
- [x] **M17.1** — Keyboard navigation audit (Tab order, focus trap in modals, Escape closes, arrow key grids)
|
||||
- [x] **M17.2** — ARIA audit (`aria-label` all icon buttons, `aria-live` dynamic content, dialog roles)
|
||||
- [x] **M17.3** — High contrast mode (`prefers-contrast: more` + Settings toggle, higher border/text opacity)
|
||||
- [x] **M17.4** — Automated accessibility tests (axe-core in Playwright, `pnpm test:a11y`, fail on critical)
|
||||
- [x] **M17.5** — i18n foundation (`vue-i18n`, extract strings to `en.json`, add `es.json` + `fr.json`)
|
||||
- [x] **M17.6** — RTL layout support (`dir="rtl"` for Arabic/Hebrew, logical CSS properties throughout)
|
||||
- [x] **M17.7** — Dyslexia-friendly font (OpenDyslexic WOFF2 self-hosted, Settings → Appearance → Font)
|
||||
- [x] **M17.8** — Skip navigation link (hidden "Skip to main content" visible on Tab focus, jumps to `<main>`)
|
||||
- [x] **TEST:M17** — Run `pnpm test`, `pnpm test:a11y`, and `pnpm typecheck && pnpm lint`. Fix all failures.
|
||||
|
||||
## M18: Performance
|
||||
|
||||
- [x] **M18.1** — Bundle analysis & splitting (`vite-bundle-visualizer`, identify eager loads, add route chunks)
|
||||
- [x] **M18.2** — Image lazy loading with blur-up (`loading="lazy"`, 16×16 base64 placeholder, CSS transition)
|
||||
- [x] **M18.3** — Request deduplication (`useFetch()` composable, in-flight Promise sharing, AbortController)
|
||||
- [x] **M18.4** — Web Worker for heavy tasks (`heavy.worker.ts` via Comlink, contentExtraction + crypto)
|
||||
- [x] **M18.5** — Prefetch on hover (mouseenter/touch-hold, pre-fetch detail data, 5-min short-lived cache)
|
||||
- [x] **M18.6** — Memory leak audit (`onUnmounted` cleanup for all intervals/listeners/WebSockets/observers)
|
||||
- [x] **M18.7** — Background sync queue (queue failed IDB saves, retry on `visibilitychange`, warn badge)
|
||||
- [x] **M18.8** — OPFS storage backend (SQLite WASM via `@sqlite.org/sqlite-wasm`, `VITE_STORAGE=opfs` flag)
|
||||
- [x] **TEST:M18** — Run `pnpm test`, `pnpm build`, verify gzipped bundle < 250 KB. Fix if over budget.
|
||||
|
||||
## M19: Developer Experience & Quality
|
||||
|
||||
- [x] **M19.1** — Storybook 8 (stories for all `ui/` components, dark glass canvas, `pnpm storybook`)
|
||||
- [x] **M19.2** — Visual regression tests (Playwright screenshots for key views, fail on > 0.5% pixel diff)
|
||||
- [x] **M19.3** — Bundle size CI gate (`bundlesize`, fail if > 250 KB gzipped, PR comment with diff)
|
||||
- [x] **M19.4** — Comprehensive mock data (20+ items per content type, mock Nostr relay, mock TMDB)
|
||||
- [x] **M19.5** — E2E cross-browser matrix (Chromium + Firefox + WebKit, mobile viewports iPhone 14 + Galaxy S21)
|
||||
- [x] **M19.6** — Proxy integration tests (mock Anthropic API, assert SSE format, tool_use, error codes, disconnect)
|
||||
- [x] **M19.7** — Lighthouse CI (LCP < 3s, CLS < 0.15, scores posted as PR comment, stored in `reports/`)
|
||||
- [x] **M19.8** — Dependency audit (`pnpm audit` + `license-checker`, weekly job, block on critical vulns)
|
||||
- [x] **TEST:M19** — Run full suite: `pnpm test && pnpm test:e2e && pnpm typecheck && pnpm lint`. All must pass.
|
||||
|
||||
## M20: Collaboration & Sharing
|
||||
|
||||
- [x] **M20.1** — Share conversation via Nostr (export as kind:30023 long-form article, sign NIP-07, optional encrypt)
|
||||
- [x] **M20.2** — Read-only conversation viewer (`/view/:nostrAddr` route, renders shared Nostr conversation)
|
||||
- [x] **M20.3** — Collaborative playlist NIP-51 (shared kind:30004 list, invite by npub, merge contributions)
|
||||
- [x] **M20.4** — Conversation templates (pre-built starters as glass cards, system prompt + first message, NIP share)
|
||||
- [x] **M20.5** — Export as audio podcast (Web Speech API TTS, WAV export via Web Audio, optional music mix)
|
||||
- [x] **M20.6** — Community content packs (import curated JSON from GitHub/IPFS, listed in plugin marketplace)
|
||||
- [x] **TEST:M20** — Run `pnpm test && pnpm typecheck && pnpm lint`. All green before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Final Sign-off
|
||||
|
||||
- [x] **FINAL** — Run `pnpm test && pnpm test:e2e && pnpm typecheck && pnpm lint && pnpm build`. All pass. Update PROGRESS.md with completion summary. Tag release `v2.0.0`.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
(Claude adds brief completion notes here as tasks finish.)
|
||||
- [ ] **T10** — Final verification pass. Run `pnpm test` (all tests pass), `pnpm typecheck` (no new type errors — archyBridge.ts error is pre-existing and acceptable), `pnpm lint` (0 errors). Fix any issues found. Commit all remaining changes with a summary commit message.
|
||||
|
||||
+26
-14
@@ -1,9 +1,19 @@
|
||||
You are executing the AIUI product roadmap autonomously. Read these files first:
|
||||
You are hardening the AIUI content extraction pipeline and completing incomplete features. Read these files first:
|
||||
|
||||
1. `loop/plan.md` — Your task checklist (mark items `- [x]` as you complete them)
|
||||
2. `PLAN2.md` — Detailed specs for each task (M8.1, M8.2, etc.)
|
||||
3. `CLAUDE.md` — Project conventions, design system rules, and coding standards
|
||||
4. `PROGRESS.md` — Update the session log after each task
|
||||
2. `CLAUDE.md` — Project conventions, design system rules, and coding standards
|
||||
|
||||
## Key Context
|
||||
|
||||
The content extraction pipeline lives in:
|
||||
- `packages/app/src/composables/contentExtraction.ts` — Extracts books, films, TV, songs, podcasts, places, images, code blocks, apps from AI response text
|
||||
- `packages/app/src/composables/contentFiltering.ts` — Query/response classifiers (isBookQuery, isTVQuery, etc.) and tab routing (filterTabsByContext)
|
||||
- `packages/app/src/composables/useContentPanel.ts` — Wires extraction functions to Vue reactive state and tab system
|
||||
- `packages/app/src/components/content/ContentPanel.vue` — Main content panel rendering grids and detail views
|
||||
- `packages/app/src/components/content/ContentGridView.vue` — Grid view (already has ImageGrid/PlaceGrid wired)
|
||||
- `packages/app/src/__tests__/extractionQuality.test.ts` — Extraction quality test suite (currently 64 tests)
|
||||
|
||||
Content flows: AI response text → extraction functions → useContentPanel refs → filterTabsByContext → ContentPanel/ContentGridView rendering
|
||||
|
||||
## Design System Rules (must follow for every file you touch)
|
||||
|
||||
@@ -19,22 +29,24 @@ You are executing the AIUI product roadmap autonomously. Read these files first:
|
||||
## For each task in loop/plan.md:
|
||||
|
||||
1. Find the first unchecked `- [ ]` item
|
||||
2. Read the detailed spec from `PLAN2.md` for that task code (e.g. M8.1)
|
||||
3. Implement the feature following the spec and design system rules above
|
||||
4. Run `pnpm typecheck && pnpm lint` — fix all errors before committing
|
||||
5. Run `pnpm test` — fix any failing tests
|
||||
6. If the task has a **Testing Gate** (`pnpm test:e2e` or specific test file), run it and fix failures
|
||||
7. Commit: `type(scope): description` (conventional commits)
|
||||
8. Mark it done `- [x]` in `loop/plan.md`
|
||||
9. Update `PROGRESS.md` session log
|
||||
2. Read the task description carefully
|
||||
3. Read the relevant source files before making changes
|
||||
4. Implement the feature/fix following conventions and design system rules
|
||||
5. Run `pnpm typecheck && pnpm lint` — fix all errors before committing
|
||||
6. Run `pnpm test` — fix any failing tests
|
||||
7. If the task involves tests, run `cd packages/app && npx vitest run src/__tests__/extractionQuality.test.ts` specifically
|
||||
8. Commit: `type(scope): description` (conventional commits)
|
||||
9. Mark it done `- [x]` in `loop/plan.md`
|
||||
10. Move to the next unchecked task immediately
|
||||
|
||||
## Rules
|
||||
|
||||
- Never skip a testing gate — if tests fail, fix them before moving on
|
||||
- If a task is proving difficult, keep trying different approaches. Make at least 30 genuine attempts before giving up and moving on. If you do move on, note the attempt count in the Notes section and leave the task unchecked.
|
||||
- If a task is proving difficult, keep trying different approaches. Make at least 30 genuine attempts before giving up and moving on.
|
||||
- Always run typecheck + lint after every code change
|
||||
- Keep initial bundle < 250 KB gzipped — lazy-load anything heavy (PDF viewer, Mermaid, KaTeX, etc.)
|
||||
- Keep initial bundle < 250 KB gzipped — lazy-load anything heavy
|
||||
- Bitcoin only — never altcoins, never fiat payment flows
|
||||
- Privacy first — no external analytics calls, no telemetry
|
||||
- Do not stop until all tasks are checked or you are rate limited
|
||||
- When adding tests, aim for realistic AI response patterns, not trivial examples
|
||||
- When wiring components into ContentPanel.vue, follow the exact same pattern used for existing content types (BookGrid/BookDetail, TVSeriesGrid/TVSeriesDetail, etc.)
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
/**
|
||||
* Extraction Quality Tests
|
||||
*
|
||||
* Tests real-world AI response patterns to verify content surfacing.
|
||||
* Each test simulates a user query + AI response and checks that
|
||||
* the right content types are extracted with correct data.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
extractAllFilms,
|
||||
extractAllSongs,
|
||||
extractAllPodcasts,
|
||||
extractAllBooks,
|
||||
extractAllTVSeries,
|
||||
extractAllPlaces,
|
||||
extractAllImages,
|
||||
extractCodeBlocks,
|
||||
extractApps,
|
||||
} from '@/composables/contentExtraction'
|
||||
import {
|
||||
isBookQuery, isBookLikeResponse,
|
||||
isTVQuery, isPlaceQuery, isPlaceLikeResponse,
|
||||
isMusicQuery, isCodeQuery, isCodeLikeResponse,
|
||||
isNewsQuery, isWebsitesQuery, isAppQuery,
|
||||
filterTabsByContext,
|
||||
} from '@/composables/contentFiltering'
|
||||
|
||||
// ─── Helper: simulate full pipeline ─────────────────────────────
|
||||
|
||||
function extractAll(text: string, userQuery: string) {
|
||||
const films = extractAllFilms(text)
|
||||
const songs = extractAllSongs(text, userQuery)
|
||||
const podcasts = extractAllPodcasts(text)
|
||||
const books = extractAllBooks(text, userQuery)
|
||||
const tvSeries = extractAllTVSeries(text, userQuery)
|
||||
const images = extractAllImages(text, userQuery)
|
||||
const places = extractAllPlaces(text, userQuery)
|
||||
const codeBlocks = extractCodeBlocks(text)
|
||||
const apps = extractApps(text, userQuery)
|
||||
return { films, songs, podcasts, books, tvSeries, images, places, codeBlocks, apps }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// BOOKS — pattern-based extraction
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Books: pattern extraction from AI responses', () => {
|
||||
it('extracts books from "Title by Author" format', () => {
|
||||
const text = `Here are some essential Bitcoin books:
|
||||
|
||||
The Bitcoin Standard by Saifedean Ammous is a great starting point. It covers the history of money and why Bitcoin matters.
|
||||
|
||||
You might also enjoy Mastering Bitcoin by Andreas Antonopoulos for the technical side.`
|
||||
const books = extractAllBooks(text, 'recommend bitcoin books')
|
||||
expect(books.length).toBeGreaterThanOrEqual(2)
|
||||
expect(books.some(b => b.title.includes('Bitcoin Standard'))).toBe(true)
|
||||
expect(books.some(b => b.title.includes('Mastering Bitcoin'))).toBe(true)
|
||||
})
|
||||
|
||||
it('extracts books from numbered list with bold and "by"', () => {
|
||||
const text = `Top books on money:
|
||||
|
||||
1. **The Bitcoin Standard** by Saifedean Ammous
|
||||
2. **The Fiat Standard** by Saifedean Ammous
|
||||
3. **Broken Money** by Lyn Alden
|
||||
4. **The Price of Tomorrow** by Jeff Booth`
|
||||
const books = extractAllBooks(text, 'books about money')
|
||||
expect(books.length).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
|
||||
it('extracts books from em-dash format', () => {
|
||||
const text = `Essential reads:
|
||||
|
||||
- The Sovereign Individual — James Dale Davidson (1997)
|
||||
- The Bitcoin Standard — Saifedean Ammous (2018)
|
||||
- Broken Money — Lyn Alden (2023)`
|
||||
const books = extractAllBooks(text, 'book recommendations')
|
||||
expect(books.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('single "by Author" triggers isBookLikeResponse', () => {
|
||||
const text = 'I highly recommend The Bitcoin Standard by Saifedean Ammous. It is an excellent book on monetary theory.'
|
||||
expect(isBookLikeResponse(text)).toBe(true)
|
||||
})
|
||||
|
||||
it('isBookQuery matches common book queries', () => {
|
||||
expect(isBookQuery('best books about bitcoin')).toBe(true)
|
||||
expect(isBookQuery('what should I read')).toBe(true)
|
||||
expect(isBookQuery('recommend me a novel')).toBe(true)
|
||||
expect(isBookQuery('any good nonfiction books')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not extract books from film responses', () => {
|
||||
const text = '[[film_ext:Inception|2010|Christopher Nolan]] is great. Directed by Christopher Nolan.'
|
||||
const books = extractAllBooks(text, 'what is inception')
|
||||
expect(books).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TV SERIES — pattern-based extraction
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('TV Series: pattern extraction from AI responses', () => {
|
||||
it('extracts TV shows from numbered list with years', () => {
|
||||
const text = `Best TV dramas of all time:
|
||||
|
||||
1. **Breaking Bad** (2008–2013)
|
||||
2. **The Wire** (2002–2008)
|
||||
3. **The Sopranos** (1999–2007)
|
||||
4. **Better Call Saul** (2015–2022)`
|
||||
const series = extractAllTVSeries(text, 'best tv shows')
|
||||
expect(series.length).toBeGreaterThanOrEqual(4)
|
||||
expect(series.some(s => s.title.includes('Breaking Bad'))).toBe(true)
|
||||
})
|
||||
|
||||
it('extracts shows with "N seasons" format', () => {
|
||||
const text = `Some great binge watches:
|
||||
|
||||
**Breaking Bad** — 5 seasons of incredible storytelling
|
||||
**Better Call Saul** — 6 seasons, a worthy prequel`
|
||||
const series = extractAllTVSeries(text, 'what should I binge')
|
||||
expect(series.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('isTVLikeResponse triggers with single season mention', () => {
|
||||
const text = 'Breaking Bad ran for 5 seasons on AMC and is widely considered one of the best TV dramas ever made.'
|
||||
expect(isTVQuery('best tv shows')).toBe(true)
|
||||
// Should pass with just 1 "season" mention now
|
||||
const hasKeyword = /\b(season|episodes?|showrunner|streaming|renewed|cancelled|premiere|network|HBO|Netflix|AMC)\b/i.test(text)
|
||||
expect(hasKeyword).toBe(true)
|
||||
})
|
||||
|
||||
it('does not extract TV from non-TV contexts', () => {
|
||||
const text = 'Bitcoin has seen a new season of adoption. The network grows stronger.'
|
||||
const series = extractAllTVSeries(text, 'what is bitcoin')
|
||||
expect(series).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PLACES — pattern-based extraction
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Places: pattern extraction from AI responses', () => {
|
||||
it('extracts places with explicit type words', () => {
|
||||
const text = `Best pizza in New York:
|
||||
|
||||
1. **Joe's Pizza** — classic New York pizza joint since 1975
|
||||
2. **Di Fara Pizza** — legendary Brooklyn pizzeria`
|
||||
const places = extractAllPlaces(text, 'best pizza in new york')
|
||||
expect(places.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('extracts places from bold + description without type word (place query)', () => {
|
||||
const text = `Great dinner spots in Austin:
|
||||
|
||||
1. **Franklin Barbecue** — World-famous brisket, expect a long line but worth every minute
|
||||
2. **Uchi** — Innovative Japanese cuisine with a Texas twist
|
||||
3. **Launderette** — New American comfort food in a converted laundromat`
|
||||
const places = extractAllPlaces(text, 'where to eat in austin')
|
||||
expect(places.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('isPlaceQuery matches food/restaurant queries', () => {
|
||||
expect(isPlaceQuery('best restaurants in london')).toBe(true)
|
||||
expect(isPlaceQuery('where to eat in tokyo')).toBe(true)
|
||||
expect(isPlaceQuery('good brunch spots')).toBe(true)
|
||||
expect(isPlaceQuery('I am hungry')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not extract places from non-place contexts', () => {
|
||||
const text = '**Bitcoin** — A peer-to-peer electronic cash system'
|
||||
const places = extractAllPlaces(text, 'what is bitcoin')
|
||||
expect(places).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SONGS — coexistence with other content types
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Songs: coexistence with other content', () => {
|
||||
it('returns explicit song tags even when film tags present', () => {
|
||||
const text = `Great movie with an amazing soundtrack:
|
||||
[[film_ext:Guardians of the Galaxy|2014|James Gunn]]
|
||||
|
||||
Featured songs:
|
||||
[[song_ext:Hooked on a Feeling|Blue Swede|1974]]
|
||||
[[song_ext:Come and Get Your Love|Redbone|1974]]`
|
||||
const songs = extractAllSongs(text, 'guardians of the galaxy soundtrack')
|
||||
expect(songs.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('returns explicit song tags even when book tags present', () => {
|
||||
const text = `[[book_ext:Norwegian Wood|Haruki Murakami|1987]]
|
||||
|
||||
The title references the Beatles song:
|
||||
[[song_ext:Norwegian Wood|The Beatles|1965]]`
|
||||
const songs = extractAllSongs(text, 'tell me about norwegian wood')
|
||||
expect(songs.length).toBeGreaterThanOrEqual(1)
|
||||
expect(songs[0].title).toBe('Norwegian Wood')
|
||||
})
|
||||
|
||||
it('skips pattern-based songs when film tags present (no explicit song tags)', () => {
|
||||
const text = `[[film_ext:Inception|2010|Christopher Nolan]]
|
||||
|
||||
Great film. The music by Hans Zimmer is amazing — "Time" is iconic.`
|
||||
const songs = extractAllSongs(text, 'tell me about inception')
|
||||
expect(songs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('extracts songs for music queries', () => {
|
||||
const text = `Here are some great rock songs:
|
||||
|
||||
"Bohemian Rhapsody" by Queen is a masterpiece.
|
||||
"Stairway to Heaven" by Led Zeppelin is another classic.`
|
||||
const songs = extractAllSongs(text, 'best rock songs')
|
||||
expect(songs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// CODE BLOCKS — extraction and classification
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Code blocks: extraction and classification', () => {
|
||||
it('extracts fenced code blocks with language', () => {
|
||||
const text = `Here's a simple function:
|
||||
|
||||
\`\`\`typescript
|
||||
function greet(name: string): string {
|
||||
return \`Hello, \${name}!\`
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
And here's the Python version:
|
||||
|
||||
\`\`\`python
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
\`\`\`
|
||||
`
|
||||
const blocks = extractCodeBlocks(text)
|
||||
expect(blocks).toHaveLength(2)
|
||||
expect(blocks[0].language).toBe('typescript')
|
||||
expect(blocks[1].language).toBe('python')
|
||||
expect(blocks[0].code).toContain('function greet')
|
||||
})
|
||||
|
||||
it('extracts code blocks without language specifier', () => {
|
||||
const text = `Run this command:
|
||||
|
||||
\`\`\`
|
||||
npm install
|
||||
\`\`\`
|
||||
`
|
||||
const blocks = extractCodeBlocks(text)
|
||||
expect(blocks).toHaveLength(1)
|
||||
expect(blocks[0].language).toBe('text')
|
||||
})
|
||||
|
||||
it('extracts label from preceding heading', () => {
|
||||
const text = `**Setup Script**
|
||||
\`\`\`bash
|
||||
#!/bin/bash
|
||||
echo "Setting up..."
|
||||
\`\`\`
|
||||
`
|
||||
const blocks = extractCodeBlocks(text)
|
||||
expect(blocks).toHaveLength(1)
|
||||
expect(blocks[0].label).toBe('Setup Script')
|
||||
})
|
||||
|
||||
it('isCodeQuery matches programming queries', () => {
|
||||
expect(isCodeQuery('write me a function to sort an array')).toBe(true)
|
||||
expect(isCodeQuery('how to implement binary search in python')).toBe(true)
|
||||
expect(isCodeQuery('debug this javascript error')).toBe(true)
|
||||
expect(isCodeQuery('best typescript libraries')).toBe(true)
|
||||
})
|
||||
|
||||
it('isCodeQuery does not match non-code queries', () => {
|
||||
expect(isCodeQuery('best movies of 2024')).toBe(false)
|
||||
expect(isCodeQuery('recommend restaurants in paris')).toBe(false)
|
||||
})
|
||||
|
||||
it('isCodeLikeResponse triggers with 3+ code blocks', () => {
|
||||
const text = '```js\na\n```\n```py\nb\n```\n```go\nc\n```'
|
||||
expect(isCodeLikeResponse(text)).toBe(true)
|
||||
})
|
||||
|
||||
it('isCodeLikeResponse does not trigger with 1-2 blocks', () => {
|
||||
const text = '```js\na\n```\nSome text\n```py\nb\n```'
|
||||
expect(isCodeLikeResponse(text)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// IMAGES — threshold behavior
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Images: threshold and alt text', () => {
|
||||
it('returns single image for image queries', () => {
|
||||
const text = ''
|
||||
const images = extractAllImages(text, 'show me a sunset image')
|
||||
expect(images).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns single image with meaningful alt text', () => {
|
||||
const text = ''
|
||||
const images = extractAllImages(text, 'bitcoin price')
|
||||
expect(images).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('skips single image without alt text for non-image query', () => {
|
||||
const text = 'https://example.com/random.jpg'
|
||||
const images = extractAllImages(text, 'what is bitcoin')
|
||||
expect(images).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 2+ images for any query', () => {
|
||||
const text = 'https://example.com/a.jpg https://example.com/b.png'
|
||||
const images = extractAllImages(text, 'what is bitcoin')
|
||||
expect(images).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TAB FILTERING — comprehensive routing
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Tab filtering: correct tabs surfaced', () => {
|
||||
it('surfaces book tab for book query with books', () => {
|
||||
const tabs = filterTabsByContext('best bitcoin books', false, false, false, true, false, false, false, false, false, false, false, false, false)
|
||||
expect(tabs).toContain('book')
|
||||
})
|
||||
|
||||
it('surfaces code tab for code query with code', () => {
|
||||
const tabs = filterTabsByContext('write a typescript function', false, false, false, false, false, false, false, false, false, false, false, false, true)
|
||||
expect(tabs).toContain('code')
|
||||
expect(tabs[0]).toBe('code') // should be first
|
||||
})
|
||||
|
||||
it('surfaces multiple tabs when multiple content types present', () => {
|
||||
const tabs = filterTabsByContext('movies and music', true, true, false, false, false, false, false, false, false, false, false, false, false)
|
||||
expect(tabs).toContain('film')
|
||||
expect(tabs).toContain('song')
|
||||
})
|
||||
|
||||
it('surfaces place tab for restaurant query', () => {
|
||||
const tabs = filterTabsByContext('best restaurants in london', false, false, false, false, false, false, true, false, false, false, false, false, false)
|
||||
expect(tabs).toContain('place')
|
||||
expect(tabs[0]).toBe('place')
|
||||
})
|
||||
|
||||
it('surfaces TV tab for TV query', () => {
|
||||
const tabs = filterTabsByContext('best tv shows to binge', false, false, false, false, true, false, false, false, false, false, false, false, false)
|
||||
expect(tabs).toContain('tvshow')
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// FULL PIPELINE — realistic AI responses
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Full pipeline: realistic AI responses', () => {
|
||||
it('book recommendation response', () => {
|
||||
const query = 'recommend some books about bitcoin and economics'
|
||||
const response = `Here are my top recommendations:
|
||||
|
||||
1. **The Bitcoin Standard** by Saifedean Ammous — The definitive work on why Bitcoin matters for sound money.
|
||||
2. **Broken Money** by Lyn Alden — A thorough analysis of the monetary system and how Bitcoin fits in.
|
||||
3. **The Fiat Standard** by Saifedean Ammous — The sequel exploring the fiat monetary system.
|
||||
4. **The Price of Tomorrow** by Jeff Booth — How deflation will shape the future economy.
|
||||
5. **Mastering Bitcoin** by Andreas Antonopoulos — Technical deep-dive into how Bitcoin works.
|
||||
|
||||
Each of these books offers a unique perspective on money, technology, and economics.`
|
||||
|
||||
const result = extractAll(response, query)
|
||||
expect(result.books.length).toBeGreaterThanOrEqual(4)
|
||||
expect(result.films).toHaveLength(0)
|
||||
expect(result.songs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('restaurant recommendation response', () => {
|
||||
const query = 'best sushi in tokyo'
|
||||
const response = `Here are Tokyo's finest sushi restaurants:
|
||||
|
||||
1. **Sukiyabashi Jiro** — The legendary 3-Michelin-star omakase experience in Ginza
|
||||
2. **Sushi Saito** — Another 3-star establishment known for exquisite nigiri
|
||||
3. **Sushi Yoshitake** — Intimate counter seating with seasonal specialties
|
||||
4. **Kyubey** — Classic Ginza sushi since 1935, welcoming to tourists`
|
||||
|
||||
const result = extractAll(response, query)
|
||||
expect(result.places.length).toBeGreaterThanOrEqual(3)
|
||||
expect(result.books).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('coding response with multiple code blocks', () => {
|
||||
const query = 'implement binary search in typescript'
|
||||
const response = `Here's a binary search implementation:
|
||||
|
||||
**Iterative approach**
|
||||
\`\`\`typescript
|
||||
function binarySearch(arr: number[], target: number): number {
|
||||
let left = 0
|
||||
let right = arr.length - 1
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2)
|
||||
if (arr[mid] === target) return mid
|
||||
if (arr[mid] < target) left = mid + 1
|
||||
else right = mid - 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Recursive approach**
|
||||
\`\`\`typescript
|
||||
function binarySearchRecursive(arr: number[], target: number, left = 0, right = arr.length - 1): number {
|
||||
if (left > right) return -1
|
||||
const mid = Math.floor((left + right) / 2)
|
||||
if (arr[mid] === target) return mid
|
||||
if (arr[mid] < target) return binarySearchRecursive(arr, target, mid + 1, right)
|
||||
return binarySearchRecursive(arr, target, left, mid - 1)
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Usage**
|
||||
\`\`\`typescript
|
||||
const arr = [1, 3, 5, 7, 9, 11]
|
||||
console.log(binarySearch(arr, 7)) // 3
|
||||
console.log(binarySearchRecursive(arr, 7)) // 3
|
||||
\`\`\`
|
||||
`
|
||||
const result = extractAll(response, query)
|
||||
expect(result.codeBlocks.length).toBeGreaterThanOrEqual(3)
|
||||
expect(result.codeBlocks[0].language).toBe('typescript')
|
||||
expect(result.codeBlocks[0].label).toBe('Iterative approach')
|
||||
expect(isCodeQuery(query)).toBe(true)
|
||||
expect(isCodeLikeResponse(response)).toBe(true)
|
||||
})
|
||||
|
||||
it('TV show recommendation response', () => {
|
||||
const query = 'best tv series of all time'
|
||||
const response = `Here are the greatest TV series ever made:
|
||||
|
||||
1. **Breaking Bad** (2008–2013) — A chemistry teacher turned drug lord. 5 seasons of perfect television.
|
||||
2. **The Wire** (2002–2008) — A sprawling look at Baltimore's institutions. 5 seasons.
|
||||
3. **The Sopranos** (1999–2007) — The show that started the golden age of TV. 6 seasons.
|
||||
4. **Mad Men** (2007–2015) — 1960s advertising world, beautifully crafted. 7 seasons.
|
||||
5. **Chernobyl** (2019) — A devastating miniseries about the nuclear disaster.`
|
||||
|
||||
const result = extractAll(response, query)
|
||||
expect(result.tvSeries.length).toBeGreaterThanOrEqual(4)
|
||||
expect(result.films).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('mixed film and song tags coexist', () => {
|
||||
const query = 'tell me about the guardians of the galaxy soundtrack'
|
||||
const response = `Guardians of the Galaxy has one of the best movie soundtracks:
|
||||
|
||||
[[film_ext:Guardians of the Galaxy|2014|James Gunn]]
|
||||
|
||||
The "Awesome Mix Vol. 1" features:
|
||||
[[song_ext:Hooked on a Feeling|Blue Swede|1974]]
|
||||
[[song_ext:Come and Get Your Love|Redbone|1974]]
|
||||
[[song_ext:Spirit in the Sky|Norman Greenbaum|1969]]
|
||||
[[song_ext:Escape (The Piña Colada Song)|Rupert Holmes|1979]]`
|
||||
|
||||
const result = extractAll(response, query)
|
||||
expect(result.films.length).toBeGreaterThanOrEqual(1)
|
||||
expect(result.songs.length).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// EDGE CASES — tricky patterns that commonly fail
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Edge cases: commonly failing patterns', () => {
|
||||
it('does not false-positive books from link markdown', () => {
|
||||
const text = 'Check out [The Bitcoin Standard](https://example.com) by visiting the website.'
|
||||
const books = extractAllBooks(text, 'bitcoin resources')
|
||||
// Should not extract "The Bitcoin Standard" as a book from a markdown link
|
||||
expect(books.every(b => !b.title.includes('Bitcoin Standard'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not extract place names from non-place bold text', () => {
|
||||
const text = `Key Bitcoin concepts:
|
||||
|
||||
1. **Proof of Work** — The consensus mechanism that secures Bitcoin
|
||||
2. **Hash Rate** — The computational power of the network`
|
||||
const places = extractAllPlaces(text, 'explain bitcoin')
|
||||
expect(places).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles quoted titles in book extraction', () => {
|
||||
const text = '"The Bitcoin Standard" by Saifedean Ammous is essential reading for understanding sound money.'
|
||||
const books = extractAllBooks(text, 'what books should I read about bitcoin')
|
||||
expect(books.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('does not extract TV series from Bitcoin season references', () => {
|
||||
const text = 'This is a new season for Bitcoin adoption. The network hashrate reached new highs.'
|
||||
const series = extractAllTVSeries(text, 'bitcoin news')
|
||||
expect(series).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('extracts books from smart/curly quotes', () => {
|
||||
const text = '\u201CThe Bitcoin Standard\u201D by Saifedean Ammous is a must-read.'
|
||||
const books = extractAllBooks(text, 'bitcoin books')
|
||||
expect(books.length).toBeGreaterThanOrEqual(1)
|
||||
expect(books[0].title).toContain('Bitcoin Standard')
|
||||
})
|
||||
|
||||
it('extracts places when query mentions pizza', () => {
|
||||
expect(isPlaceQuery('best pizza near me')).toBe(true)
|
||||
expect(isPlaceQuery('pizza recommendations')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not false-positive songs from film director "by" pattern', () => {
|
||||
const text = '[[film_ext:Inception|2010|Christopher Nolan]]\n\nInception, directed by Christopher Nolan, is a mind-bending thriller.'
|
||||
const songs = extractAllSongs(text, 'tell me about inception')
|
||||
expect(songs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles books with single-word titles in bold', () => {
|
||||
const text = '1. **Sapiens** by Yuval Noah Harari — A brief history of humankind'
|
||||
const books = extractAllBooks(text, 'best nonfiction books')
|
||||
expect(books.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('extracts multiple places from varied formatting', () => {
|
||||
const query = 'where to eat in paris'
|
||||
const text = `Top restaurants in Paris:
|
||||
|
||||
1. **Le Comptoir du Panth\u00e9on** \u2014 Classic French bistro with incredible steak frites
|
||||
2. **Chez Janou** \u2014 Famous for its chocolate mousse, cozy Proven\u00e7al atmosphere
|
||||
3. **L'As du Fallafel** \u2014 Best falafel in the Marais, always a queue`
|
||||
const places = extractAllPlaces(text, query)
|
||||
expect(places.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('extracts TV shows from "created by" format', () => {
|
||||
const text = `1. **Breaking Bad** (2008\u20132013) \u2014 Created by Vince Gilligan. A chemistry teacher becomes a drug lord.
|
||||
2. **The Wire** (2002\u20132008) \u2014 Created by David Simon. A deep look at Baltimore institutions.`
|
||||
const series = extractAllTVSeries(text, 'best tv dramas')
|
||||
expect(series.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('does not extract books when response is about films', () => {
|
||||
const text = `Great sci-fi films:
|
||||
|
||||
1. **Blade Runner** (1982) \u2014 Directed by Ridley Scott
|
||||
2. **2001: A Space Odyssey** (1968) \u2014 Directed by Stanley Kubrick`
|
||||
const books = extractAllBooks(text, 'best sci-fi movies')
|
||||
expect(books).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('extracts code blocks with varied languages', () => {
|
||||
const text = `**HTML**
|
||||
\`\`\`html
|
||||
<div class="container">Hello</div>
|
||||
\`\`\`
|
||||
|
||||
**CSS**
|
||||
\`\`\`css
|
||||
.container { color: red; }
|
||||
\`\`\`
|
||||
|
||||
**JavaScript**
|
||||
\`\`\`javascript
|
||||
document.querySelector('.container')
|
||||
\`\`\`
|
||||
`
|
||||
const blocks = extractCodeBlocks(text)
|
||||
expect(blocks).toHaveLength(3)
|
||||
expect(blocks[0].language).toBe('html')
|
||||
expect(blocks[1].language).toBe('css')
|
||||
expect(blocks[2].language).toBe('javascript')
|
||||
})
|
||||
|
||||
it('surfaces correct tabs for multi-content response', () => {
|
||||
// Books + code in same response
|
||||
const tabs = filterTabsByContext(
|
||||
'how to learn programming',
|
||||
false, false, false, true, false, false, false, false, false, false, false, false, true
|
||||
)
|
||||
expect(tabs).toContain('book')
|
||||
expect(tabs).toContain('code')
|
||||
})
|
||||
|
||||
it('prefers place tab first for food queries', () => {
|
||||
const tabs = filterTabsByContext(
|
||||
'best pizza in new york',
|
||||
false, false, false, false, false, false, true, false, false, false, false, false, false
|
||||
)
|
||||
expect(tabs[0]).toBe('place')
|
||||
})
|
||||
|
||||
it('extracts books from prose with "also enjoy" mid-sentence', () => {
|
||||
const text = `I'd recommend starting with The Bitcoin Standard by Saifedean Ammous. You might also enjoy Mastering Bitcoin by Andreas Antonopoulos for the technical deep dive.`
|
||||
const books = extractAllBooks(text, 'bitcoin book recommendations')
|
||||
expect(books.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('extracts places with cuisine type in description', () => {
|
||||
const text = `Best spots in Brooklyn:
|
||||
|
||||
1. **Lucali** — Beloved BYOB pizzeria with incredible thin-crust pies
|
||||
2. **Peter Luger** — Iconic steakhouse since 1887, cash only
|
||||
3. **Olmsted** — Innovative New American restaurant with a backyard garden`
|
||||
const places = extractAllPlaces(text, 'where to eat in brooklyn')
|
||||
expect(places.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('does not false-positive places from tech concepts', () => {
|
||||
const text = `**React** — A JavaScript library for building user interfaces
|
||||
**Vue** — The progressive JavaScript framework
|
||||
**Angular** — A platform for building mobile and desktop web apps`
|
||||
const places = extractAllPlaces(text, 'best javascript frameworks')
|
||||
expect(places).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('extracts songs from "Artist - Title" format', () => {
|
||||
const text = `Classic rock essentials:
|
||||
|
||||
[[song_ext:Stairway to Heaven|Led Zeppelin|1971]]
|
||||
[[song_ext:Hotel California|Eagles|1977]]
|
||||
[[song_ext:Comfortably Numb|Pink Floyd|1979]]`
|
||||
const songs = extractAllSongs(text, 'best classic rock songs')
|
||||
expect(songs.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('handles TV query "what should I watch"', () => {
|
||||
expect(isTVQuery('what should I watch tonight')).toBe(true)
|
||||
expect(isTVQuery('anything good to binge')).toBe(true)
|
||||
expect(isTVQuery('best tv comedies')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles place query with cuisine names', () => {
|
||||
expect(isPlaceQuery('best sushi in tokyo')).toBe(true)
|
||||
expect(isPlaceQuery('good ramen spots')).toBe(true)
|
||||
expect(isPlaceQuery('where to get tacos')).toBe(true)
|
||||
})
|
||||
|
||||
it('full pipeline: mixed books and songs response', () => {
|
||||
const query = 'tell me about norwegian wood'
|
||||
const text = `"Norwegian Wood" can refer to both a novel and a song:
|
||||
|
||||
**The Novel**: "Norwegian Wood" by Haruki Murakami (1987) is a nostalgic story of love and loss set in 1960s Tokyo. It's one of Murakami's most accessible works.
|
||||
|
||||
**The Song**: [[song_ext:Norwegian Wood (This Bird Has Flown)|The Beatles|1965]]
|
||||
|
||||
The Beatles' track from Rubber Soul inspired Murakami's title. The song features George Harrison's sitar playing.`
|
||||
const result = extractAll(text, query)
|
||||
expect(result.books.length).toBeGreaterThanOrEqual(1)
|
||||
expect(result.songs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('full pipeline: place response without explicit type words', () => {
|
||||
const query = 'best brunch in london'
|
||||
const text = `Here are London's best brunch spots:
|
||||
|
||||
1. **Dishoom** — Bombay-inspired breakfast, try the bacon naan roll and chai
|
||||
2. **The Wolseley** — Grand European cafe on Piccadilly, impeccable service
|
||||
3. **Padella** — Fresh handmade pasta, worth the queue at Borough Market
|
||||
4. **Bao** — Taiwanese steamed buns and small plates, Soho or Fitzrovia`
|
||||
const result = extractAll(text, query)
|
||||
expect(result.places.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('extracts TV from [[tv_ext:]] tags with Title|Year|Creator format', () => {
|
||||
const text = `Similar series:\n\n[[tv_ext:Ghost in the Shell: Stand Alone Complex|2002|Kenji Kamiyama]] — Gold standard for tech-noir sci-fi.\n\n[[tv_ext:Cyberpunk: Edgerunners|2022|Hiroyuki Imaishi]] — Stunning animation.\n\n[[tv_ext:Altered Carbon|2018|Laeta Kalogridis]] — Cyberpunk noir.`
|
||||
const result = extractAll(text, 'pantheon news')
|
||||
expect(result.tvSeries.length).toBeGreaterThanOrEqual(3)
|
||||
expect(result.tvSeries[0].title).toBe('Ghost in the Shell: Stand Alone Complex')
|
||||
expect(result.tvSeries[0].year).toBe(2002)
|
||||
expect(result.tvSeries[0].creator).toBe('Kenji Kamiyama')
|
||||
})
|
||||
|
||||
it('news query with TV content surfaces both news and TV tabs', () => {
|
||||
// Simulates the Pantheon response: news query + TV ext tags
|
||||
const tabs = filterTabsByContext(
|
||||
'any news on pantheon season 3',
|
||||
false, false, false, false, true, false, false, false, true, false, false, false, false
|
||||
)
|
||||
expect(tabs).toContain('tvshow')
|
||||
expect(tabs).toContain('websites')
|
||||
})
|
||||
|
||||
it('full pipeline: news query does not surface books or places', () => {
|
||||
const text = `Here are the latest developments:
|
||||
|
||||
Bitcoin has surged past $100,000 for the first time. The rally was driven by institutional adoption and ETF inflows. Major exchanges like Coinbase and Kraken reported record trading volumes.`
|
||||
const result = extractAll(text, 'latest bitcoin news')
|
||||
expect(result.books).toHaveLength(0)
|
||||
expect(result.places).toHaveLength(0)
|
||||
expect(result.tvSeries).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -77,8 +77,13 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="path-glass-bubble rounded-2xl px-4 py-3 flex items-end gap-2 transition-all duration-300"
|
||||
:class="focused ? 'border-white/30' : ''"
|
||||
class="rounded-2xl px-4 py-3 flex items-end gap-2 transition-all duration-300"
|
||||
:class="[
|
||||
isCodeMode
|
||||
? 'bg-accent/15 border border-accent/25 backdrop-blur-xl'
|
||||
: 'path-glass-bubble',
|
||||
focused ? (isCodeMode ? 'border-accent/40' : 'border-white/30') : '',
|
||||
]"
|
||||
>
|
||||
<!-- Image attach button -->
|
||||
<button
|
||||
@@ -131,10 +136,14 @@
|
||||
<!-- Send button -->
|
||||
<button
|
||||
:disabled="!canSend"
|
||||
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200"
|
||||
:class="canSend
|
||||
? 'hover:opacity-80 active:scale-95'
|
||||
: 'opacity-30 cursor-not-allowed'"
|
||||
class="shrink-0 rounded-xl px-3 transition-all duration-200"
|
||||
:class="[
|
||||
isCodeMode ? 'bg-accent/80 text-white' : 'path-glass-button path-glass-button-sm',
|
||||
canSend
|
||||
? 'hover:opacity-80 active:scale-95'
|
||||
: 'opacity-30 cursor-not-allowed',
|
||||
]"
|
||||
:style="isCodeMode ? 'height: 32px' : ''"
|
||||
aria-label="Send message"
|
||||
@click="send"
|
||||
>
|
||||
@@ -172,16 +181,20 @@ const props = withDefaults(
|
||||
disabled?: boolean
|
||||
streaming?: boolean
|
||||
placeholder?: string
|
||||
activeTab?: string
|
||||
replyTo?: { messageId: string; excerpt: string } | null
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
streaming: false,
|
||||
placeholder: 'Message AIUI...',
|
||||
activeTab: '',
|
||||
replyTo: null,
|
||||
}
|
||||
)
|
||||
|
||||
const isCodeMode = computed(() => props.activeTab === 'code')
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [text: string, images: ImageAttachment[]]
|
||||
extract: [text: string]
|
||||
|
||||
@@ -106,7 +106,8 @@
|
||||
<ChatInput
|
||||
:disabled="isStreaming || comparison.isAnyStreaming.value"
|
||||
:streaming="isStreaming || comparison.isAnyStreaming.value"
|
||||
:placeholder="isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
|
||||
:placeholder="activeTab === 'code' ? 'Code...' : isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
|
||||
:active-tab="activeTab"
|
||||
:reply-to="replyTo"
|
||||
@send="handleSend"
|
||||
@extract="handleExtract"
|
||||
|
||||
@@ -87,6 +87,7 @@ 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.codeBlocks?.length ?? 0) > 0) badges.push('Code')
|
||||
if ((content.apps?.length ?? 0) > 0) badges.push('Apps')
|
||||
if (content.hasNostr) badges.push('Nostr')
|
||||
}
|
||||
|
||||
@@ -37,12 +37,29 @@
|
||||
<button
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="text-left p-3 rounded-xl transition-colors group cursor-pointer"
|
||||
:class="isDark
|
||||
? 'bg-white/[0.03] hover:bg-white/[0.07]'
|
||||
: 'bg-black/[0.02] hover:bg-black/[0.05]'"
|
||||
class="text-left p-3 rounded-xl transition-all duration-150 group relative"
|
||||
:class="[
|
||||
codeMode && isDesignTokenSelected(item.id)
|
||||
? 'ring-2 ring-accent/50 bg-accent/10 cursor-pointer'
|
||||
: isDark
|
||||
? 'bg-white/[0.03] hover:bg-white/[0.07] cursor-pointer'
|
||||
: 'bg-black/[0.02] hover:bg-black/[0.05] cursor-pointer',
|
||||
]"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<!-- Selection toggle (top-right) — only this area toggles context selection -->
|
||||
<div
|
||||
v-if="codeMode"
|
||||
class="absolute top-2 right-2 w-5 h-5 rounded-full flex items-center justify-center z-10 cursor-pointer transition-colors"
|
||||
:class="isDesignTokenSelected(item.id)
|
||||
? 'bg-accent'
|
||||
: isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-black/10 hover:bg-black/20'"
|
||||
@click.stop="toggleDesignToken(item.id)"
|
||||
>
|
||||
<svg v-if="isDesignTokenSelected(item.id)" class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Preview swatch for colors -->
|
||||
<div v-if="item.category === 'colors' && item.preview === 'inline'"
|
||||
class="h-8 rounded-md mb-2 border"
|
||||
@@ -92,9 +109,11 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useContentPanel, type DesignSystemItem } from '@/composables/useContentPanel'
|
||||
import { useCodeContext } from '@/composables/useCodeContext'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const { openDesignSystemItem } = useContentPanel()
|
||||
const { codeMode, toggleDesignToken, isDesignTokenSelected } = useCodeContext()
|
||||
|
||||
const activeCategory = ref<string>('all')
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
<button
|
||||
class="w-full text-left flex items-center gap-1.5 py-1 px-2 rounded-lg text-xs transition-colors"
|
||||
:class="[
|
||||
isActive
|
||||
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
|
||||
: isDark ? 'text-white/60 hover:bg-white/[0.04] hover:text-white/80' : 'text-gray-600 hover:bg-black/[0.03] hover:text-gray-800',
|
||||
isSelected
|
||||
? 'bg-accent/15 text-accent ring-1 ring-accent/30'
|
||||
: isActive
|
||||
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
|
||||
: isDark ? 'text-white/60 hover:bg-white/[0.04] hover:text-white/80' : 'text-gray-600 hover:bg-black/[0.03] hover:text-gray-800',
|
||||
]"
|
||||
:style="{ paddingLeft: `${depth * 12 + 8}px` }"
|
||||
@click="handleClick"
|
||||
@@ -33,6 +35,9 @@
|
||||
</svg>
|
||||
|
||||
<span class="truncate">{{ entry.name }}</span>
|
||||
<svg v-if="isSelected" class="w-3 h-3 shrink-0 text-accent ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Children (when expanded) -->
|
||||
@@ -52,7 +57,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import type { FileEntry } from '@/composables/useCodeContext'
|
||||
import { useCodeContext, type FileEntry } from '@/composables/useCodeContext'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: FileEntry
|
||||
@@ -60,6 +65,8 @@ const props = defineProps<{
|
||||
depth: number
|
||||
}>()
|
||||
|
||||
const { isFileSelected } = useCodeContext()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [path: string]
|
||||
}>()
|
||||
@@ -68,6 +75,7 @@ const { isDark } = useTheme()
|
||||
const expanded = ref(props.depth < 1) // Auto-expand first level
|
||||
|
||||
const isActive = computed(() => !props.entry.isDirectory && props.activeFile === props.entry.path)
|
||||
const isSelected = computed(() => !props.entry.isDirectory && isFileSelected(props.entry.path))
|
||||
|
||||
function handleClick() {
|
||||
if (props.entry.isDirectory) {
|
||||
|
||||
@@ -172,10 +172,14 @@ const {
|
||||
activeProject,
|
||||
fileTree,
|
||||
activeFile,
|
||||
codeMode,
|
||||
selectedFiles,
|
||||
selectProject: doSelectProject,
|
||||
openFile,
|
||||
createProject,
|
||||
clearActiveFile,
|
||||
toggleFileSelection,
|
||||
isFileSelected,
|
||||
} = useCodeContext()
|
||||
|
||||
const search = ref('')
|
||||
@@ -215,6 +219,7 @@ function backToProjects() {
|
||||
}
|
||||
|
||||
function handleFileSelect(filePath: string) {
|
||||
toggleFileSelection(filePath)
|
||||
openFile(filePath)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export const PODCAST_TAG_RE = /\[\[podcast:(p?\d+)\]\]/gi
|
||||
export const PODCAST_EXT_RE = /\[\[podcast_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
|
||||
export const BOOK_TAG_RE = /\[\[book:(b?\d+)\]\]/gi
|
||||
export const BOOK_EXT_RE = /\[\[book_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
|
||||
export const TV_EXT_RE = /\[\[tv_ext:([^|]+)\|([^|]*)(?:\|(\d{4}))?\]\]/gi
|
||||
export const TV_EXT_RE = /\[\[tv_ext:([^|]+)\|([^|\]]+)(?:\|([^|\]]+))?\]\]/gi
|
||||
export const PLACE_EXT_RE = /\[\[place_ext:([^|]+)\|([^|]*)(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?\]\]/gi
|
||||
export const MARKDOWN_LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g
|
||||
const SAFE_URL_SCHEME = /^https?:\/\//i
|
||||
@@ -602,12 +602,25 @@ function extractSongsFromPatterns(text: string): Song[] {
|
||||
export function extractAllSongs(text: string, userQuery = ''): Song[] {
|
||||
const librarySongs = resolveSongs(extractSongIds(text))
|
||||
const externalSongs = extractExternalSongs(text)
|
||||
if (librarySongs.length > 0 || externalSongs.length > 0) {
|
||||
return [...librarySongs, ...externalSongs]
|
||||
// Explicit song tags and library matches always returned
|
||||
const explicitSongs = [...librarySongs, ...externalSongs]
|
||||
|
||||
// Skip pattern-based fallback when conflicting content tags are present
|
||||
const hasConflictingTags = extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text) ||
|
||||
extractPodcastIds(text).length > 0 || /\[\[podcast_ext:/.test(text) ||
|
||||
/\[\[tv_ext:/.test(text) || /\[\[book_ext:/.test(text)
|
||||
if (explicitSongs.length > 0) {
|
||||
if (hasConflictingTags) return explicitSongs
|
||||
// Also grab pattern matches alongside explicit songs
|
||||
const libMatches = extractSongsFromLibraryMatch(text)
|
||||
const patternMatches = extractSongsFromPatterns(text)
|
||||
const existingKeys = new Set(explicitSongs.map((s) => `${s.title.toLowerCase()}|${s.artist.toLowerCase()}`))
|
||||
const extras = [...libMatches, ...patternMatches].filter(
|
||||
(p) => !existingKeys.has(`${p.title.toLowerCase()}|${p.artist.toLowerCase()}`)
|
||||
)
|
||||
return [...explicitSongs, ...extras]
|
||||
}
|
||||
if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) return []
|
||||
if (extractPodcastIds(text).length > 0 || /\[\[podcast_ext:/.test(text)) return []
|
||||
if (/\[\[tv_ext:/.test(text) || /\[\[book_ext:/.test(text)) return []
|
||||
if (hasConflictingTags) return []
|
||||
if (isNewsLikeResponse(text)) return []
|
||||
const q = userQuery.toLowerCase()
|
||||
if (q && !isMusicQuery(q)) return []
|
||||
@@ -723,9 +736,20 @@ function extractBooksFromPatterns(text: string): Book[] {
|
||||
const seen = new Set<string>()
|
||||
|
||||
const patterns: { re: RegExp; titleIdx: number; authorIdx: number }[] = [
|
||||
{ re: /["""]([^"""]{2,80})["""]\s+by\s+([A-Z][^,\n.]{1,50}?)(?:\s*[,()\n.]|$)/gi, titleIdx: 1, authorIdx: 2 },
|
||||
{ re: /\*\*([^*]{2,80})\*\*\s+(?:by|—|–)\s+\*?([A-Z][^*\n]{1,50}?)\*?(?:\s*[,()*\n]|$)/g, titleIdx: 1, authorIdx: 2 },
|
||||
{ re: /(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n\-–—]{2,80}?)\*{0,2}\s+(?:by|—|–)\s+\*?([A-Z][^*\n]{1,50}?)\*?(?:\s*[,()*\n]|$)/gm, titleIdx: 1, authorIdx: 2 },
|
||||
// "Title" by Author or "Title" by Author (straight and smart quotes)
|
||||
{ re: /[""\u201C\u201D]([^"""\u201C\u201D]{2,80})[""\u201C\u201D]\s+by\s+([A-Z][^,\n]{1,50}?)(?:\s+(?:is|was|has|—|–|-)|[,()\n.]|$)/gi, titleIdx: 1, authorIdx: 2 },
|
||||
// **Title** by/—/– Author
|
||||
{ re: /\*\*([^*]{2,80})\*\*\s+(?:by|—|–)\s+\*?([A-Z][^*\n]{1,50}?)\*?(?:\s+(?:is|was|has|—|–|-)|[,()*\n.]|$)/g, titleIdx: 1, authorIdx: 2 },
|
||||
// List item: "1. Title by Author" or "- Title by Author"
|
||||
{ re: /(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n\-–—]{2,80}?)\*{0,2}\s+(?:by|—|–)\s+\*?([A-Z][^*\n]{1,50}?)\*?(?:\s+(?:is|was|has|—|–|-)|[,()*\n.]|$)/gm, titleIdx: 1, authorIdx: 2 },
|
||||
// Numbered list: "1. **Title** by Author — Description"
|
||||
{ re: /(?:^|\n)\s*\d+\.\s*\*\*([^*]{2,80})\*\*\s+by\s+([A-Z][^,\n(—–-]{1,50}?)(?:\s*[,()\n—–-]|$)/gm, titleIdx: 1, authorIdx: 2 },
|
||||
// Em-dash list: "- Title — Author (year)" or "- Title — Author"
|
||||
{ re: /(?:^|\n)\s*[-•]\s+([^—–\n]{2,80}?)\s+[—–]\s+([A-Z][^,\n(]{1,50}?)(?:\s*\(\d{4}\))?(?:\s*[,\n]|$)/gm, titleIdx: 1, authorIdx: 2 },
|
||||
// Inline prose at line/sentence start: "Title Words by Author"
|
||||
{ re: /(?:^|\n|[.!?]\s+)([A-Z][a-z]+(?:\s+[A-Z][a-z]*){1,8})\s+by\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]*){0,4})(?:\s+[a-z]|[,.()\n—–-]|$)/gm, titleIdx: 1, authorIdx: 2 },
|
||||
// After recommendation verbs: "enjoy Title by Author"
|
||||
{ re: /(?:read|enjoy|recommend|check out|try|start with|pick up)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]*){1,8})\s+by\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]*){0,4})(?:\s+[a-z]|[,.()\n—–-]|$)/gi, titleIdx: 1, authorIdx: 2 },
|
||||
]
|
||||
|
||||
for (const { re, titleIdx, authorIdx } of patterns) {
|
||||
@@ -764,10 +788,6 @@ export function extractAllBooks(text: string, userQuery: string): Book[] {
|
||||
const externalBooks = extractExternalBooks(text)
|
||||
if (externalBooks.length > 0) return externalBooks
|
||||
if (!isBookQuery(userQuery) && !isBookLikeResponse(text)) return []
|
||||
if (!isBookQuery(userQuery)) {
|
||||
if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) return []
|
||||
if (extractSongIds(text).length > 0 || /\[\[song_ext:/.test(text)) return []
|
||||
}
|
||||
if (isNewsLikeResponse(text)) return []
|
||||
const cleanText = text.replace(/\n---\n\s*(?:Sources|References|Links):?\s*\n[\s\S]*$/i, '')
|
||||
return extractBooksFromPatterns(cleanText)
|
||||
@@ -782,8 +802,13 @@ function extractExternalTVSeries(text: string): TVSeries[] {
|
||||
const re = new RegExp(TV_EXT_RE.source, 'gi')
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const title = match[1].trim()
|
||||
const creator = match[2].trim()
|
||||
const year = match[3] ? parseInt(match[3], 10) : undefined
|
||||
const field2 = match[2].trim()
|
||||
const field3 = match[3]?.trim()
|
||||
// Handle both Title|Year|Creator and Title|Creator|Year formats
|
||||
const isField2Year = /^\d{4}$/.test(field2)
|
||||
const year = isField2Year ? parseInt(field2, 10)
|
||||
: field3 && /^\d{4}$/.test(field3) ? parseInt(field3, 10) : undefined
|
||||
const creator = isField2Year ? (field3 || undefined) : (field2 || undefined)
|
||||
const key = title.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
@@ -801,8 +826,18 @@ function extractExternalTVSeries(text: string): TVSeries[] {
|
||||
}
|
||||
|
||||
function isTVLikeResponse(text: string): boolean {
|
||||
return /\b(season|episodes?|showrunner|streaming|renewed|cancelled|premiere|network|HBO|Netflix|AMC|FX|Apple TV|Disney\+)\b/i.test(text) &&
|
||||
(text.match(/\bseason\b/gi)?.length ?? 0) >= 2
|
||||
const hasKeyword = /\b(season|episodes?|showrunner|streaming|renewed|cancelled|premiere|network|HBO|Netflix|AMC|FX|Apple TV|Disney\+|created by)\b/i.test(text)
|
||||
if (!hasKeyword) return false
|
||||
// Single "season" mention is sufficient
|
||||
if ((text.match(/\bseason\b/gi)?.length ?? 0) >= 1) return true
|
||||
// Multiple distinct TV signals without requiring "season"
|
||||
const tvSignals = [
|
||||
/\bepisodes?\b/i, /\bshowrunner\b/i, /\bstreaming\b/i, /\brenewed\b/i,
|
||||
/\bcancelled\b/i, /\bpremiere\b/i, /\bnetwork\b/i,
|
||||
/\b(HBO|Netflix|AMC|FX|Apple TV|Disney\+|Hulu|Amazon Prime)\b/i,
|
||||
/\bseries\b/i, /\bpilot\b/i, /\bminiseries\b/i, /\bcreated by\b/i,
|
||||
]
|
||||
return tvSignals.filter(re => re.test(text)).length >= 2
|
||||
}
|
||||
|
||||
function extractTVSeriesFromPatterns(text: string): TVSeries[] {
|
||||
@@ -812,6 +847,10 @@ function extractTVSeriesFromPatterns(text: string): TVSeries[] {
|
||||
/"([^"]{2,60})"\s*[-–—]\s*(?:a |an )?(?:series|show|tv)/gi,
|
||||
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*(?:a |an )?(?:\w+ )?(?:series|show|drama|comedy|thriller|animated)/gi,
|
||||
/(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n]{2,60}?)\*{0,2}\s*\((\d{4})(?:[-–]\d{0,4})?(?:,\s*\d+ seasons?)?\)/gm,
|
||||
// Bold title with seasons info: **Title** — 5 seasons
|
||||
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*\d+\s*seasons?/gi,
|
||||
// "Title" — creator (in TV context)
|
||||
/"([^"]{2,60})"\s*[-–—]\s*([A-Z][^,\n.]{1,50}?)(?:\s*[,()\n]|$)/gi,
|
||||
]
|
||||
for (const re of patterns) {
|
||||
let m: RegExpExecArray | null
|
||||
@@ -918,6 +957,8 @@ export function extractAllImages(text: string, userQuery: string): ImageItem[] {
|
||||
const images = extractImages(text)
|
||||
if (images.length === 0) return []
|
||||
if (isImageQuery(userQuery) || images.length >= 2) return images
|
||||
// Show single image if it has meaningful alt text (intentional image context)
|
||||
if (images.length === 1 && images[0].alt && images[0].alt.length > 2) return images
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -957,9 +998,16 @@ function extractPlacesFromPatterns(text: string): Place[] {
|
||||
const places: { name: string; cuisine?: string; city?: string; rating?: number; priceLevel?: number; desc: string; pos: number }[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const placeTypeWords = 'restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|eatery|spot|joint|trattoria|taqueria|brasserie|cantina|chophouse|creamery|diner|tavern'
|
||||
const patterns: RegExp[] = [
|
||||
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*(?:a |an )?(?:(\w[\w\s]{1,30}?)\s+)?(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|eatery|spot|joint)/gi,
|
||||
/(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n]{2,60}?)\*{0,2}\s*[-–—(]\s*(?:(\w[\w\s&]{1,30}?)\s+)?(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|cuisine|food|dining)/gim,
|
||||
// **Name** — ... type word (within 80 chars of dash)
|
||||
new RegExp(`\\*\\*([^*]{2,60})\\*\\*\\s*[-–—:]\\s*(?:a |an )?(?:(\\w[\\w\\s]{1,30}?)\\s+)?(?:${placeTypeWords})`, 'gi'),
|
||||
// List item with type word
|
||||
new RegExp(`(?:^|\\n)\\s*(?:\\d+\\.\\s*|[-•]\\s*)\\*{0,2}([^*\\n]{2,60}?)\\*{0,2}\\s*[-–—(]\\s*(?:(\\w[\\w\\s&]{1,30}?)\\s+)?(?:${placeTypeWords}|cuisine|food|dining)`, 'gim'),
|
||||
// **Name** — description containing a place type word anywhere in the next 120 chars
|
||||
new RegExp(`\\*\\*([^*]{2,60})\\*\\*\\s*[-–—:]\\s*([^\\n]{3,120}?\\b(?:${placeTypeWords})\\b[^\\n]{0,40})`, 'gi'),
|
||||
// List: 1. **Name** — description with place type word
|
||||
new RegExp(`(?:^|\\n)\\s*(?:\\d+\\.\\s*|[-•]\\s*)\\*\\*([^*]{2,60})\\*\\*\\s*[-–—:]\\s*([^\\n]{3,120}?\\b(?:${placeTypeWords})\\b[^\\n]{0,40})`, 'gim'),
|
||||
]
|
||||
|
||||
for (const re of patterns) {
|
||||
@@ -996,12 +1044,71 @@ function extractPlacesFromPatterns(text: string): Place[] {
|
||||
}))
|
||||
}
|
||||
|
||||
function extractPlacesFromBoldPatterns(text: string): Place[] {
|
||||
const places: { name: string; desc: string; pos: number }[] = []
|
||||
const seen = new Set<string>()
|
||||
// Match any bold title followed by dash/colon and description (for place-query context)
|
||||
const re = /(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*\*([^*]{2,60})\*\*\s*[-–—:]\s*([^\n]{5,200})/gm
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const name = m[1].trim()
|
||||
if (name.length < 2) continue
|
||||
if (/\[\[(film|song|podcast|book|tv|place)(_ext)?:/.test(name)) continue
|
||||
const key = name.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
places.push({ name, desc: m[2].trim(), pos: m.index })
|
||||
}
|
||||
return places
|
||||
.sort((a, b) => a.pos - b.pos)
|
||||
.map(({ name, desc }) => ({
|
||||
id: `ext-place-${name.toLowerCase().replace(/\W/g, '-')}`,
|
||||
name,
|
||||
description: desc,
|
||||
sources: [],
|
||||
}))
|
||||
}
|
||||
|
||||
export function extractAllPlaces(text: string, userQuery: string): Place[] {
|
||||
const external = extractExternalPlaces(text)
|
||||
if (external.length > 0) return external
|
||||
if (!isPlaceQuery(userQuery) && !isPlaceLikeResponse(text)) return []
|
||||
if (isNewsLikeResponse(text)) return []
|
||||
return extractPlacesFromPatterns(text)
|
||||
const fromPatterns = extractPlacesFromPatterns(text)
|
||||
// For place queries, supplement with bold-title patterns to catch items without type words
|
||||
if (isPlaceQuery(userQuery)) {
|
||||
const fromBold = extractPlacesFromBoldPatterns(text)
|
||||
const seenNames = new Set(fromPatterns.map(p => p.name.toLowerCase()))
|
||||
const extras = fromBold.filter(p => !seenNames.has(p.name.toLowerCase()))
|
||||
const combined = [...fromPatterns, ...extras]
|
||||
return combined.length > 0 ? combined : []
|
||||
}
|
||||
return fromPatterns
|
||||
}
|
||||
|
||||
// ─── Code block extraction ─────────────────────────────────────────
|
||||
|
||||
export interface CodeBlock {
|
||||
language: string
|
||||
code: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function extractCodeBlocks(text: string): CodeBlock[] {
|
||||
const blocks: CodeBlock[] = []
|
||||
const re = /```(\w*)\n([\s\S]*?)```/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const language = m[1] || 'text'
|
||||
const code = m[2].trimEnd()
|
||||
if (code.length < 3) continue
|
||||
// Try to find a label from the line above the code block
|
||||
const before = text.slice(Math.max(0, m.index - 200), m.index)
|
||||
const labelMatch = /(?:^|\n)\s*(?:\*\*([^*]{2,80})\*\*|#+\s+(.{2,80}))\s*\n?\s*$/.exec(before)
|
||||
const label = labelMatch?.[1] || labelMatch?.[2] || undefined
|
||||
blocks.push({ language, code, label })
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// ─── Tag stripping ────────────────────────────────────────────────
|
||||
|
||||
@@ -59,12 +59,12 @@ export function isBookQuery(q: string): boolean {
|
||||
|
||||
export function isBookLikeResponse(text: string): boolean {
|
||||
return /\b(novel|author|pages?|ISBN|published|bestsell|literary|fiction|nonfiction|book)\b/i.test(text) &&
|
||||
(text.match(/\bby\s+[A-Z]/g)?.length ?? 0) >= 2
|
||||
(text.match(/\bby\s+[A-Z]/g)?.length ?? 0) >= 1
|
||||
}
|
||||
|
||||
export function isTVQuery(q: string): boolean {
|
||||
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)
|
||||
return /\b(tv\b|tv shows?|tv series|series|television|streaming|binge|watch|recommend.*shows?|best shows?|seasons?|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 (shows?|series) to|new (shows?|series)|best (shows?|series)|recommend.*(shows?|series|watch)/i.test(q)
|
||||
}
|
||||
|
||||
export function isImageQuery(q: string): boolean {
|
||||
@@ -72,7 +72,7 @@ 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|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) ||
|
||||
return /\b(restaurant|restaurants|place|places|food|eat|eating|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizza|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)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,19 @@ export function isPlaceLikeResponse(text: string): boolean {
|
||||
(text.match(/\b(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill)\b/gi)?.length ?? 0) >= 2
|
||||
}
|
||||
|
||||
// ─── Code classifiers ───────────────────────────────────────────
|
||||
|
||||
export function isCodeQuery(q: string): boolean {
|
||||
if (!q) return false
|
||||
return /\b(code|coding|programming|function|algorithm|implement|debug|syntax|API|library|framework|snippet|script|regex|refactor|compile|runtime|variable|class|method|module|package|dependency|typescript|javascript|python|rust|go|java|swift|kotlin|ruby|php|css|html|sql|bash|shell|cli|terminal|git|docker|webpack|vite|npm|yarn|pnpm)\b/i.test(q) ||
|
||||
/write (me )?a |how (do i|to) (code|program|implement|build|create|make)|show me.*code|code (example|sample)|fix.*(bug|error|issue)|what does this code/i.test(q)
|
||||
}
|
||||
|
||||
export function isCodeLikeResponse(text: string): boolean {
|
||||
const codeBlockCount = (text.match(/```[\s\S]*?```/g) ?? []).length
|
||||
return codeBlockCount >= 3
|
||||
}
|
||||
|
||||
// ─── Nostr classifiers ──────────────────────────────────────────
|
||||
|
||||
export function isNostrQuery(q: string): boolean {
|
||||
@@ -146,9 +159,10 @@ export function preferredFirstTab(userQuery: string): ContentTab | null {
|
||||
if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song'
|
||||
if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
|
||||
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(tv\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|hungry)\b/.test(q)) return 'place'
|
||||
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizza|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|hungry)\b/.test(q)) return 'place'
|
||||
if (isCodeQuery(q)) return 'code'
|
||||
if (isAppQuery(q)) return 'app'
|
||||
if (isNostrQuery(q)) return 'nostr'
|
||||
if (isNewsQuery(q)) return 'news'
|
||||
@@ -170,46 +184,12 @@ export function filterTabsByContext(
|
||||
hasMagazine: boolean,
|
||||
hasNostr: boolean,
|
||||
hasApps: boolean,
|
||||
hasCode = false,
|
||||
): 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')
|
||||
if (hasNews) tabs.push('news')
|
||||
if (hasWebsites) tabs.push('websites')
|
||||
if (hasPodcasts) tabs.push('podcast')
|
||||
return tabs
|
||||
}
|
||||
|
||||
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 && !hasNostr && !hasApps) {
|
||||
return ['websites']
|
||||
}
|
||||
|
||||
// Build the full set of detected content tabs
|
||||
const all: ContentTab[] = []
|
||||
if (hasFilms) all.push('film')
|
||||
if (hasBooks) all.push('book')
|
||||
@@ -218,12 +198,44 @@ export function filterTabsByContext(
|
||||
if (hasPlaces) all.push('place')
|
||||
if (hasSongs) all.push('song')
|
||||
if (hasPodcasts) all.push('podcast')
|
||||
if (hasCode) all.push('code')
|
||||
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')
|
||||
|
||||
// Helper: prioritize certain tabs first, then append remaining detected content
|
||||
const prioritize = (priority: ContentTab[]): ContentTab[] => {
|
||||
const present = priority.filter(t => all.includes(t))
|
||||
const rest = all.filter(t => !present.includes(t))
|
||||
return [...present, ...rest]
|
||||
}
|
||||
|
||||
// Nostr query → prioritize nostr tab
|
||||
if (isNostrQuery(q)) {
|
||||
return prioritize(['nostr', 'app', 'magazine', 'websites'])
|
||||
}
|
||||
|
||||
// App query → prioritize apps tab
|
||||
if (isAppQuery(q)) {
|
||||
const result = prioritize(['app', 'nostr', 'magazine', 'websites'])
|
||||
return result.length > 0 ? result : hasNostr ? ['nostr'] : []
|
||||
}
|
||||
|
||||
// News query → prioritize news/magazine tabs
|
||||
if (isNewsQuery(q)) {
|
||||
return prioritize(['magazine', 'news', 'websites', 'podcast'])
|
||||
}
|
||||
|
||||
if (hasMagazine && all.length === 1) {
|
||||
return ['magazine']
|
||||
}
|
||||
|
||||
if (hasWebsites && all.length === 1) {
|
||||
return ['websites']
|
||||
}
|
||||
|
||||
if (preferred && all.includes(preferred)) {
|
||||
const rest = all.filter((t) => t !== preferred)
|
||||
return [preferred, ...rest]
|
||||
|
||||
@@ -22,6 +22,8 @@ const fileTree = shallowRef<FileEntry[]>([])
|
||||
const activeFile = ref<string | null>(null)
|
||||
const activeFileContent = ref<string>('')
|
||||
const activeFileLanguage = ref<string>('plaintext')
|
||||
const selectedDesignTokens = ref<string[]>([])
|
||||
const selectedFiles = ref<string[]>([])
|
||||
|
||||
// Demo projects path
|
||||
const PROJECTS_ROOT = '/Users/dorian/Projects'
|
||||
@@ -110,6 +112,36 @@ export function useCodeContext() {
|
||||
activeFile.value = null
|
||||
activeFileContent.value = ''
|
||||
fileTree.value = []
|
||||
selectedDesignTokens.value = []
|
||||
selectedFiles.value = []
|
||||
}
|
||||
|
||||
function toggleDesignToken(id: string): void {
|
||||
const idx = selectedDesignTokens.value.indexOf(id)
|
||||
if (idx >= 0) selectedDesignTokens.value.splice(idx, 1)
|
||||
else selectedDesignTokens.value.push(id)
|
||||
}
|
||||
|
||||
function isDesignTokenSelected(id: string): boolean {
|
||||
return selectedDesignTokens.value.includes(id)
|
||||
}
|
||||
|
||||
function clearDesignTokens(): void {
|
||||
selectedDesignTokens.value = []
|
||||
}
|
||||
|
||||
function toggleFileSelection(path: string): void {
|
||||
const idx = selectedFiles.value.indexOf(path)
|
||||
if (idx >= 0) selectedFiles.value.splice(idx, 1)
|
||||
else selectedFiles.value.push(path)
|
||||
}
|
||||
|
||||
function isFileSelected(path: string): boolean {
|
||||
return selectedFiles.value.includes(path)
|
||||
}
|
||||
|
||||
function clearFileSelection(): void {
|
||||
selectedFiles.value = []
|
||||
}
|
||||
|
||||
function selectProject(project: ProjectInfo): void {
|
||||
@@ -231,6 +263,8 @@ export function useCodeContext() {
|
||||
activeFile,
|
||||
activeFileContent,
|
||||
activeFileLanguage,
|
||||
selectedDesignTokens,
|
||||
selectedFiles,
|
||||
|
||||
// Actions
|
||||
enterCodeMode,
|
||||
@@ -241,5 +275,11 @@ export function useCodeContext() {
|
||||
detectLanguage,
|
||||
createProject,
|
||||
clearActiveFile,
|
||||
toggleDesignToken,
|
||||
isDesignTokenSelected,
|
||||
clearDesignTokens,
|
||||
toggleFileSelection,
|
||||
isFileSelected,
|
||||
clearFileSelection,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,14 @@ import {
|
||||
extractMagazineSections, extractMagazineHeroImage,
|
||||
extractMarkdownLinks, extractBoldDomainLinks, extractBareDomainLinks, mergeNewsResults,
|
||||
extractFilmIds, extractSongIds, extractPodcastIds,
|
||||
extractApps,
|
||||
extractApps, extractCodeBlocks,
|
||||
stripFilmTags, stripSongTags, stripPodcastTags, stripContentTags, stripMarkdownLinks,
|
||||
} from './contentExtraction'
|
||||
import type { AppEntry } from './contentExtraction'
|
||||
import type { AppEntry, CodeBlock } from './contentExtraction'
|
||||
import {
|
||||
isNewsQuery, isNewsLikeResponse, isTVQuery,
|
||||
isNostrQuery, isNostrLikeResponse,
|
||||
isCodeQuery, isCodeLikeResponse,
|
||||
filterTabsByContext, extractQueryContext,
|
||||
} from './contentFiltering'
|
||||
export type { ContentTab, MagazineSection } from './contentFiltering'
|
||||
@@ -37,6 +38,7 @@ const panelPodcasts = ref<Podcast[]>([])
|
||||
const panelImages = ref<ImageItem[]>([])
|
||||
const panelPlaces = ref<Place[]>([])
|
||||
const panelApps = ref<AppEntry[]>([])
|
||||
const panelCodeBlocks = ref<CodeBlock[]>([])
|
||||
const selectedFilm = ref<Film | null>(null)
|
||||
const selectedBook = ref<Book | null>(null)
|
||||
const selectedTVSeries = ref<TVSeries | null>(null)
|
||||
@@ -95,6 +97,10 @@ export function useContentPanel() {
|
||||
const apps = extractApps(text, userQuery)
|
||||
const hasApps = apps.length > 0
|
||||
|
||||
// Code block extraction
|
||||
const codeBlocks = extractCodeBlocks(text)
|
||||
const hasCode = codeBlocks.length > 0 && (isCodeQuery(userQuery) || isCodeLikeResponse(text))
|
||||
|
||||
// Nostr detection
|
||||
const hasNostr = isNostrQuery(userQuery) || isNostrLikeResponse(text)
|
||||
|
||||
@@ -132,7 +138,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, hasNostr, hasApps)
|
||||
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, hasCode)
|
||||
availableTabs.value = tabs.length > 0 ? tabs : ['film']
|
||||
activeTab.value = tabs[0] ?? 'film'
|
||||
|
||||
@@ -147,6 +153,7 @@ export function useContentPanel() {
|
||||
const showWebsitesTab = tabs.includes('websites')
|
||||
const showMagazine = tabs.includes('magazine')
|
||||
const showApps = tabs.includes('app')
|
||||
const showCode = tabs.includes('code')
|
||||
|
||||
const visibleFilms = showFilms ? films : []
|
||||
const visibleBooks = showBooks ? books : []
|
||||
@@ -159,6 +166,7 @@ export function useContentPanel() {
|
||||
const visibleWebsites = showWebsitesTab ? mergedWebsites : []
|
||||
const visibleMagazineSections = showMagazine ? magazineSections : []
|
||||
const visibleApps = showApps ? apps : []
|
||||
const visibleCodeBlocks = showCode ? codeBlocks : []
|
||||
|
||||
panelFilms.value = visibleFilms
|
||||
panelBooks.value = visibleBooks
|
||||
@@ -170,6 +178,7 @@ export function useContentPanel() {
|
||||
panelWebResults.value = visibleNews
|
||||
panelWebsites.value = visibleWebsites
|
||||
panelApps.value = visibleApps
|
||||
panelCodeBlocks.value = visibleCodeBlocks
|
||||
panelMagazineSections.value = visibleMagazineSections
|
||||
panelMagazineHeroImage.value = showMagazine
|
||||
? (extractMagazineHeroImage(text) ?? webResults[0]?.imgSrc ?? null)
|
||||
@@ -224,6 +233,7 @@ export function useContentPanel() {
|
||||
const ctx = extractQueryContext(userQuery)
|
||||
panelTitle.value = ctx ? `${ctx} — Brief` : 'AI Brief'
|
||||
}
|
||||
else if (visibleCodeBlocks.length > 0) panelTitle.value = `${visibleCodeBlocks.length} Code Blocks`
|
||||
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'
|
||||
@@ -258,6 +268,8 @@ export function useContentPanel() {
|
||||
const hasWebsites = websitesLinks.length > 0
|
||||
const apps = extractApps(text, userQuery)
|
||||
const hasApps = apps.length > 0
|
||||
const codeBlocks = extractCodeBlocks(text)
|
||||
const hasCode = codeBlocks.length > 0 && (isCodeQuery(userQuery) || isCodeLikeResponse(text))
|
||||
const hasNostr = isNostrQuery(userQuery) || isNostrLikeResponse(text)
|
||||
const images = extractAllImages(text, userQuery)
|
||||
const places = extractAllPlaces(text, userQuery)
|
||||
@@ -268,7 +280,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, hasNostr, hasApps)
|
||||
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, hasCode)
|
||||
return {
|
||||
films: tabs.includes('film') ? films : [],
|
||||
books: tabs.includes('book') ? books : [],
|
||||
@@ -281,6 +293,7 @@ export function useContentPanel() {
|
||||
websitesLinks: tabs.includes('websites') ? websitesLinks : [],
|
||||
magazineSections: tabs.includes('magazine') ? magazineSections : [],
|
||||
apps: tabs.includes('app') ? apps : [],
|
||||
codeBlocks: tabs.includes('code') ? codeBlocks : [],
|
||||
hasNostr,
|
||||
}
|
||||
}
|
||||
@@ -422,6 +435,7 @@ export function useContentPanel() {
|
||||
panelWebResults,
|
||||
panelWebsites,
|
||||
panelApps,
|
||||
panelCodeBlocks,
|
||||
panelMagazineSections,
|
||||
panelMagazineHeroImage,
|
||||
selectedFilm,
|
||||
@@ -450,6 +464,7 @@ export function useContentPanel() {
|
||||
extractAllSongs,
|
||||
extractPodcastIds,
|
||||
extractAllPodcasts,
|
||||
extractCodeBlocks,
|
||||
getContextualInlineContent,
|
||||
updatePanelFromText,
|
||||
stripFilmTags,
|
||||
|
||||
Reference in New Issue
Block a user