-
- In response to
-
-
- {{ headlineText }}
-
-
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+ In response to
+
+
+ {{ headlineText }}
+
+
@@ -59,7 +80,7 @@
:class="isDark
? 'bg-[#0a0a0a] hover:bg-white/[0.03]'
: 'bg-[#faf9f6] hover:bg-black/[0.02]'"
- @click="tile.section?.url && openLink(tile.section.url)">
+ @click="tile.section && openTile(tile.section)">
@@ -91,7 +112,7 @@
? isDark ? 'bg-white/[0.04]' : 'bg-black/[0.04]'
: ''
]"
- @click="tile.section?.url && openLink(tile.section.url)">
+ @click="tile.section && openTile(tile.section)">
@@ -127,8 +148,7 @@
diff --git a/packages/app/src/components/content/NewsGrid.vue b/packages/app/src/components/content/NewsGrid.vue
index 50419311..d50eee5e 100644
--- a/packages/app/src/components/content/NewsGrid.vue
+++ b/packages/app/src/components/content/NewsGrid.vue
@@ -122,7 +122,6 @@ import { ref, computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
-import { useArticleOverlayStore } from '@/stores/articleOverlay'
const props = withDefaults(defineProps<{
articles: WebSearchResult[]
@@ -138,8 +137,7 @@ const props = withDefaults(defineProps<{
})
const { isDark } = useTheme()
-const { openArticleDetail } = useContentPanel()
-const overlayStore = useArticleOverlayStore()
+const { openArticleDetail, openWebsiteDetail } = useContentPanel()
const search = ref('')
const failedImgs = ref>(new Set())
@@ -172,7 +170,7 @@ function faviconUrl(url: string): string | null {
function openArticle(article: WebSearchResult) {
if (props.variant === 'websites') {
- overlayStore.open(article.url, article.title, undefined, article.imgSrc)
+ openWebsiteDetail(article)
} else {
openArticleDetail(article)
}
diff --git a/packages/app/src/components/content/PlaceCard.vue b/packages/app/src/components/content/PlaceCard.vue
new file mode 100644
index 00000000..6f9b84d1
--- /dev/null
+++ b/packages/app/src/components/content/PlaceCard.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
diff --git a/packages/app/src/components/content/PlaceDetail.vue b/packages/app/src/components/content/PlaceDetail.vue
new file mode 100644
index 00000000..270be57a
--- /dev/null
+++ b/packages/app/src/components/content/PlaceDetail.vue
@@ -0,0 +1,197 @@
+
+
+
+
+
diff --git a/packages/app/src/components/content/PlaceGrid.vue b/packages/app/src/components/content/PlaceGrid.vue
new file mode 100644
index 00000000..2d91a689
--- /dev/null
+++ b/packages/app/src/components/content/PlaceGrid.vue
@@ -0,0 +1,142 @@
+
+
+
+
+
+ {{ title || 'Places' }}
+
+
+
+ {{ filteredPlaces.length }} places
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/components/content/ProjectGrid.vue b/packages/app/src/components/content/ProjectGrid.vue
new file mode 100644
index 00000000..59bf5f10
--- /dev/null
+++ b/packages/app/src/components/content/ProjectGrid.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+ {{ activeProject ? activeProject.name : 'Projects' }}
+
+
+ {{ activeProject ? activeProject.language : `${projectList.length} repositories` }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/components/content/WebsiteDetail.vue b/packages/app/src/components/content/WebsiteDetail.vue
new file mode 100644
index 00000000..a665ffa8
--- /dev/null
+++ b/packages/app/src/components/content/WebsiteDetail.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+ {{ website.title || 'Website' }}
+
+
+ {{ domain }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/composables/useAI.ts b/packages/app/src/composables/useAI.ts
index 1409f74e..20204e01 100644
--- a/packages/app/src/composables/useAI.ts
+++ b/packages/app/src/composables/useAI.ts
@@ -43,6 +43,8 @@ Prioritize Podcasting 2.0–friendly platforms: Fountain.fm, Podcast Index, Cast
**TV Series:** When recommending or discussing TV series/shows, use [[tv_ext:Title|Year|Creator]], e.g. [[tv_ext:Breaking Bad|2008|Vince Gilligan]]. Do NOT use [[film_ext:...]] for TV series — use [[tv_ext:...]] instead. Write a brief reason why the show is worth watching on the same line.
+**Places/Restaurants:** When recommending restaurants, cafes, bars, or other places to visit, use [[place_ext:Name|Cuisine|City|Rating|PriceLevel|Address]], e.g. [[place_ext:Sushi Nakazawa|Japanese|New York|4.7|3|23 Commerce St]]. Rating is out of 5, PriceLevel is 1-4 ($ to $$$$). Omit fields you don't know. Write a brief description on the same line.
+
**Websites / "Best places to check":** When listing resources, places to check online, or websites for the user to visit, use markdown links: [Name](https://full-url). For simple domains use **Name** (domain.com), e.g. **Bitcoin Mailing List** (gnusha.org).
**Music discovery:** For genre-based requests (e.g. "best math rock"), pick from the user's library when relevant, or use [[song_ext:...]] for others. Prioritize indie-friendly platforms: Wavlake, Bandcamp, Internet Archive, SoundCloud, Odysee, Jamendo.
diff --git a/packages/app/src/composables/useCodeContext.ts b/packages/app/src/composables/useCodeContext.ts
new file mode 100644
index 00000000..5d473619
--- /dev/null
+++ b/packages/app/src/composables/useCodeContext.ts
@@ -0,0 +1,206 @@
+import { ref, computed, shallowRef } from 'vue'
+
+export interface ProjectInfo {
+ name: string
+ path: string
+ isGit: boolean
+ language?: string
+}
+
+export interface FileEntry {
+ name: string
+ path: string
+ isDirectory: boolean
+ children?: FileEntry[]
+}
+
+// Module-level singleton state
+const codeMode = ref(false)
+const activeProject = ref(null)
+const projectList = shallowRef([])
+const fileTree = shallowRef([])
+const activeFile = ref(null)
+const activeFileContent = ref('')
+const activeFileLanguage = ref('plaintext')
+
+// Demo projects path
+const PROJECTS_ROOT = '/Users/dorian/Projects'
+
+function detectLanguage(filename: string): string {
+ const ext = filename.split('.').pop()?.toLowerCase() ?? ''
+ const map: Record = {
+ ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
+ vue: 'vue', svelte: 'svelte', py: 'python', rs: 'rust', go: 'go',
+ java: 'java', kt: 'kotlin', swift: 'swift', rb: 'ruby', php: 'php',
+ css: 'css', scss: 'scss', html: 'html', json: 'json', yaml: 'yaml',
+ yml: 'yaml', md: 'markdown', toml: 'toml', sh: 'shell', bash: 'shell',
+ sql: 'sql', graphql: 'graphql', dockerfile: 'dockerfile',
+ c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp',
+ }
+ return map[ext] ?? 'plaintext'
+}
+
+function detectProjectLanguage(files: string[]): string {
+ if (files.includes('package.json')) return 'TypeScript/JavaScript'
+ if (files.includes('Cargo.toml')) return 'Rust'
+ if (files.includes('go.mod')) return 'Go'
+ if (files.includes('requirements.txt') || files.includes('setup.py') || files.includes('pyproject.toml')) return 'Python'
+ if (files.includes('pom.xml') || files.includes('build.gradle')) return 'Java'
+ if (files.includes('Package.swift')) return 'Swift'
+ if (files.includes('Gemfile')) return 'Ruby'
+ if (files.includes('composer.json')) return 'PHP'
+ if (files.some(f => f.endsWith('.csproj') || f.endsWith('.sln'))) return 'C#'
+ return 'Unknown'
+}
+
+export function useCodeContext() {
+ const isCodeMode = computed(() => codeMode.value)
+ const hasActiveProject = computed(() => activeProject.value !== null)
+
+ async function loadProjects(): Promise {
+ // In dev/demo mode, scan the Projects folder
+ // This would be replaced by Archy integration later
+ try {
+ const response = await fetch(`/api/fs/list?path=${encodeURIComponent(PROJECTS_ROOT)}`)
+ if (response.ok) {
+ const data = await response.json()
+ projectList.value = data.projects ?? []
+ }
+ } catch {
+ // Fallback: use hardcoded list from build time
+ // In real app, this would come from local filesystem or Archy nodes
+ projectList.value = getDemoProjects()
+ }
+ }
+
+ function getDemoProjects(): ProjectInfo[] {
+ // Hardcoded demo list matching actual ~/Projects folder
+ return [
+ { name: 'AIUI', path: `${PROJECTS_ROOT}/AIUI`, isGit: true, language: 'TypeScript/JavaScript' },
+ { name: 'archy', path: `${PROJECTS_ROOT}/archy`, isGit: true, language: 'TypeScript/JavaScript' },
+ { name: 'angor', path: `${PROJECTS_ROOT}/angor`, isGit: true, language: 'C#' },
+ { name: 'angor-prototype', path: `${PROJECTS_ROOT}/angor-prototype`, isGit: true, language: 'TypeScript/JavaScript' },
+ { name: 'archipelago', path: `${PROJECTS_ROOT}/archipelago`, isGit: true, language: 'Unknown' },
+ { name: 'archipelago-foundation', path: `${PROJECTS_ROOT}/archipelago-foundation`, isGit: true, language: 'Unknown' },
+ { name: 'blossom', path: `${PROJECTS_ROOT}/blossom`, isGit: true, language: 'TypeScript/JavaScript' },
+ { name: 'fedimint', path: `${PROJECTS_ROOT}/fedimint`, isGit: true, language: 'Rust' },
+ { name: 'Syntopy', path: `${PROJECTS_ROOT}/Syntopy`, isGit: true, language: 'Unknown' },
+ { name: 'Syntropy-Institute', path: `${PROJECTS_ROOT}/Syntropy-Institute`, isGit: true, language: 'Unknown' },
+ { name: 'LoRaBell', path: `${PROJECTS_ROOT}/LoRaBell`, isGit: true, language: 'Unknown' },
+ { name: 'satoshi-services', path: `${PROJECTS_ROOT}/satoshi-services`, isGit: true, language: 'Unknown' },
+ { name: 'Proux', path: `${PROJECTS_ROOT}/Proux`, isGit: true, language: 'Unknown' },
+ { name: 'KYC', path: `${PROJECTS_ROOT}/KYC`, isGit: true, language: 'Unknown' },
+ { name: 'k484', path: `${PROJECTS_ROOT}/k484`, isGit: true, language: 'Unknown' },
+ { name: 'tbf', path: `${PROJECTS_ROOT}/tbf`, isGit: true, language: 'Unknown' },
+ { name: 'Icon', path: `${PROJECTS_ROOT}/Icon`, isGit: false, language: 'Unknown' },
+ { name: 'indeehub-frontend', path: `${PROJECTS_ROOT}/indeehub-frontend`, isGit: true, language: 'TypeScript/JavaScript' },
+ { name: 'Indeedhub Prototype', path: `${PROJECTS_ROOT}/Indeedhub Prototype`, isGit: true, language: 'Unknown' },
+ { name: '21', path: `${PROJECTS_ROOT}/21`, isGit: true, language: 'Unknown' },
+ ]
+ }
+
+ function enterCodeMode(): void {
+ codeMode.value = true
+ loadProjects()
+ }
+
+ function exitCodeMode(): void {
+ codeMode.value = false
+ activeProject.value = null
+ activeFile.value = null
+ activeFileContent.value = ''
+ fileTree.value = []
+ }
+
+ function selectProject(project: ProjectInfo): void {
+ activeProject.value = project
+ loadFileTree(project.path)
+ }
+
+ async function loadFileTree(projectPath: string): Promise {
+ try {
+ const response = await fetch(`/api/fs/tree?path=${encodeURIComponent(projectPath)}`)
+ if (response.ok) {
+ const data = await response.json()
+ fileTree.value = data.files ?? []
+ }
+ } catch {
+ // Demo fallback: generate a simple tree
+ fileTree.value = getDemoFileTree()
+ }
+ }
+
+ function getDemoFileTree(): FileEntry[] {
+ // Generic project structure for demo
+ return [
+ { name: 'src', path: 'src', isDirectory: true, children: [
+ { name: 'index.ts', path: 'src/index.ts', isDirectory: false },
+ { name: 'app.ts', path: 'src/app.ts', isDirectory: false },
+ { name: 'utils.ts', path: 'src/utils.ts', isDirectory: false },
+ ]},
+ { name: 'package.json', path: 'package.json', isDirectory: false },
+ { name: 'tsconfig.json', path: 'tsconfig.json', isDirectory: false },
+ { name: 'README.md', path: 'README.md', isDirectory: false },
+ ]
+ }
+
+ async function openFile(filePath: string): Promise {
+ activeFile.value = filePath
+ activeFileLanguage.value = detectLanguage(filePath)
+
+ try {
+ const fullPath = activeProject.value
+ ? `${activeProject.value.path}/${filePath}`
+ : filePath
+ const response = await fetch(`/api/fs/read?path=${encodeURIComponent(fullPath)}`)
+ if (response.ok) {
+ const data = await response.json()
+ activeFileContent.value = data.content ?? ''
+ }
+ } catch {
+ // Demo fallback
+ activeFileContent.value = getDemoFileContent(filePath)
+ }
+ }
+
+ function getDemoFileContent(filePath: string): string {
+ const name = filePath.split('/').pop() ?? filePath
+ if (name === 'package.json') {
+ return JSON.stringify({
+ name: activeProject.value?.name?.toLowerCase() ?? 'project',
+ version: '1.0.0',
+ type: 'module',
+ scripts: { dev: 'vite', build: 'vite build', test: 'vitest' },
+ dependencies: {},
+ }, null, 2)
+ }
+ if (name === 'README.md') {
+ return `# ${activeProject.value?.name ?? 'Project'}\n\nA project in the AIUI ecosystem.\n`
+ }
+ if (name.endsWith('.ts') || name.endsWith('.js')) {
+ return `// ${name}\n// ${activeProject.value?.name ?? 'Project'}\n\nexport function main() {\n console.log('Hello from ${name}')\n}\n`
+ }
+ return `// ${name}\n`
+ }
+
+ return {
+ // State
+ codeMode,
+ isCodeMode,
+ activeProject,
+ hasActiveProject,
+ projectList,
+ fileTree,
+ activeFile,
+ activeFileContent,
+ activeFileLanguage,
+
+ // Actions
+ enterCodeMode,
+ exitCodeMode,
+ selectProject,
+ openFile,
+ loadProjects,
+ detectLanguage,
+ }
+}
diff --git a/packages/app/src/composables/useContentPanel.ts b/packages/app/src/composables/useContentPanel.ts
index c743ba51..84f92d0c 100644
--- a/packages/app/src/composables/useContentPanel.ts
+++ b/packages/app/src/composables/useContentPanel.ts
@@ -1,5 +1,5 @@
import { ref } from 'vue'
-import type { Film, Song, Podcast, Book, TVSeries, ImageItem } from '@aiui/core/types/content'
+import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import type { WebSearchResult } from '@aiui/core/types/message'
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
@@ -7,7 +7,7 @@ import { mockPodcasts } from '@/mocks/podcasts'
import { generatePosterFallback, generateSongCoverFallback, generateBookCoverFallback } from '@/composables/useImageFallback'
import { fetchRssFromUrls } from '@/composables/useRssFetch'
-export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'news' | 'websites' | 'magazine'
+export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'news' | 'websites' | 'magazine' | 'code'
export interface MagazineSection {
title: string
@@ -34,6 +34,7 @@ const panelMagazineHeroImage = ref(null)
const panelSongs = ref([])
const panelPodcasts = ref([])
const panelImages = ref([])
+const panelPlaces = ref([])
const selectedFilm = ref(null)
const selectedBook = ref(null)
const selectedTVSeries = ref(null)
@@ -41,6 +42,10 @@ const selectedSong = ref(null)
const selectedPodcast = ref(null)
const selectedArticle = ref(null)
const selectedImage = ref(null)
+const selectedPlace = ref(null)
+const selectedWebsite = ref(null)
+const selectedMagazineSection = ref(null)
+const magazineSectionIndex = ref(0)
const panelTitle = ref('Recommended Films')
const panelQuery = ref('')
const contentType = ref<'film' | 'song' | 'podcast'>('film')
@@ -295,6 +300,7 @@ function preferredFirstTab(userQuery: string): ContentTab | null {
if (/\b(book|books|read|reading|novel|author|nonfiction|non-fiction)\b/.test(q)) return 'book'
if (/\b(tv show|tv series|series|television|streaming|binge|watch)\b/.test(q)) return 'tvshow'
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
+ if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse)\b/.test(q)) return 'place'
if (isNewsQuery(q)) return 'news'
if (isWebsitesQuery(q)) return 'websites'
return null
@@ -310,6 +316,7 @@ function filterTabsByContext(
hasBooks: boolean,
hasTVSeries: boolean,
hasImages: boolean,
+ hasPlaces: boolean,
hasNews: boolean,
hasWebsites: boolean,
hasMagazine: boolean,
@@ -326,11 +333,11 @@ function filterTabsByContext(
return tabs
}
- if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasNews && !hasWebsites) {
+ if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasWebsites) {
return ['magazine']
}
- if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasNews && !hasMagazine) {
+ if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasMagazine) {
return ['websites']
}
@@ -339,6 +346,7 @@ function filterTabsByContext(
if (hasBooks) all.push('book')
if (hasTVSeries) all.push('tvshow')
if (hasImages) all.push('image')
+ if (hasPlaces) all.push('place')
if (hasSongs) all.push('song')
if (hasPodcasts) all.push('podcast')
if (hasMagazine) all.push('magazine')
@@ -386,6 +394,7 @@ const BOOK_TAG_RE = /\[\[book:(b?\d+)\]\]/gi
const BOOK_EXT_RE = /\[\[book_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
const TV_EXT_RE = /\[\[tv_ext:([^|]+)\|([^|]*)(?:\|(\d{4}))?\]\]/gi
+const PLACE_EXT_RE = /\[\[place_ext:([^|]+)\|([^|]*)(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?\]\]/gi
/** Reject obvious non-podcast phrases (documentation, mailing lists, etc.) */
function looksLikePodcast(title: string, host: string): boolean {
@@ -972,6 +981,101 @@ export function useContentPanel() {
return []
}
+ function isPlaceQuery(q: string): boolean {
+ return /\b(restaurant|restaurants|place|places|food|eat|eating|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|where to eat|good food|best food|where should i eat|recommend.*eat|recommend.*restaurant|recommend.*place)\b/i.test(q)
+ }
+
+ function isPlaceLikeResponse(text: string): boolean {
+ return /\b(restaurant|cuisine|menu|reserv|dining|address|open|hours|price range|\$\$|\$\$\$|michelin|yelp|rating)\b/i.test(text) &&
+ (text.match(/\b(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill)\b/gi)?.length ?? 0) >= 2
+ }
+
+ /** Extract places from [[place_ext:Name|Cuisine|City|Rating|PriceLevel|Address]] tags */
+ function extractExternalPlaces(text: string): Place[] {
+ const places: Place[] = []
+ const seen = new Set()
+ let match: RegExpExecArray | null
+ const re = new RegExp(PLACE_EXT_RE.source, 'gi')
+ while ((match = re.exec(text)) !== null) {
+ const name = match[1].trim()
+ const cuisine = match[2]?.trim() || undefined
+ const city = match[3]?.trim() || undefined
+ const rating = match[4] ? parseFloat(match[4]) : undefined
+ const priceLevel = match[5] ? parseInt(match[5], 10) : undefined
+ const address = match[6]?.trim() || undefined
+ const key = name.toLowerCase()
+ if (seen.has(key)) continue
+ seen.add(key)
+ places.push({
+ id: `ext-place-${key.replace(/\W/g, '-')}`,
+ name,
+ cuisine,
+ city,
+ rating: rating && !isNaN(rating) ? rating : undefined,
+ priceLevel: priceLevel && priceLevel >= 1 && priceLevel <= 4 ? priceLevel : undefined,
+ address,
+ description: extractDescriptionForTag(text, match.index, match[0].length),
+ sources: [],
+ })
+ }
+ return places
+ }
+
+ /** Extract place-like patterns from AI response text */
+ 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()
+
+ const patterns: RegExp[] = [
+ // **Name** — cuisine/category, details
+ /\*\*([^*]{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,
+ // Numbered list: 1. **Name** (cuisine) or 1. Name — description
+ /(?:^|\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,
+ ]
+
+ for (const re of patterns) {
+ let m: RegExpExecArray | null
+ const rx = new RegExp(re.source, re.flags)
+ while ((m = rx.exec(text)) !== null) {
+ const name = m[1].trim().replace(/^\*\*|\*\*$/g, '').replace(/^\[|\]$/g, '')
+ 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)
+ const cuisine = m[2]?.trim()
+ const desc = extractDescriptionForTag(text, m.index, m[0].length)
+ // Try to extract rating from nearby text
+ const nearby = text.slice(m.index, m.index + 300)
+ const ratingMatch = /(\d\.?\d?)\s*(?:\/\s*5|stars?|★)/i.exec(nearby)
+ const rating = ratingMatch ? parseFloat(ratingMatch[1]) : undefined
+ const priceMatch = /(\${1,4})\b/.exec(nearby)
+ const priceLevel = priceMatch ? priceMatch[1].length : undefined
+ places.push({ name, cuisine, desc, rating, priceLevel, pos: m.index })
+ }
+ }
+
+ return places
+ .sort((a, b) => a.pos - b.pos)
+ .map(({ name, cuisine, desc, rating, priceLevel }) => ({
+ id: `ext-place-${name.toLowerCase().replace(/\W/g, '-')}`,
+ name,
+ cuisine,
+ rating,
+ priceLevel,
+ description: desc,
+ sources: [],
+ }))
+ }
+
+ 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)
+ }
+
function updatePanelFromText(text: string, userQuery = '', webResults: WebSearchResult[] = []) {
panelQuery.value = userQuery.trim()
const songs = extractAllSongs(text)
@@ -1021,8 +1125,9 @@ export function useContentPanel() {
}
const images = extractAllImages(text, userQuery)
+ const places = extractAllPlaces(text, userQuery)
- const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, hasNews, hasWebsites, hasMagazine)
+ const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine)
availableTabs.value = tabs.length > 0 ? tabs : ['film']
activeTab.value = tabs[0] ?? 'film'
@@ -1030,6 +1135,7 @@ export function useContentPanel() {
const showBooks = tabs.includes('book')
const showTVSeries = tabs.includes('tvshow')
const showImages = tabs.includes('image')
+ const showPlaces = tabs.includes('place')
const showSongs = tabs.includes('song')
const showPodcasts = tabs.includes('podcast')
const showNews = tabs.includes('news')
@@ -1040,6 +1146,7 @@ export function useContentPanel() {
const visibleBooks = showBooks ? books : []
const visibleTVSeries = showTVSeries ? tvSeries : []
const visibleImages = showImages ? images : []
+ const visiblePlaces = showPlaces ? places : []
const visibleSongs = showSongs ? songs : []
const visiblePodcasts = showPodcasts ? podcasts : []
const visibleNews = showNews ? mergedNews : []
@@ -1050,6 +1157,7 @@ export function useContentPanel() {
panelBooks.value = visibleBooks
panelTVSeries.value = visibleTVSeries
panelImages.value = visibleImages
+ panelPlaces.value = visiblePlaces
panelSongs.value = visibleSongs
panelPodcasts.value = visiblePodcasts
panelWebResults.value = visibleNews
@@ -1062,6 +1170,7 @@ export function useContentPanel() {
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
+ selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
@@ -1075,7 +1184,9 @@ export function useContentPanel() {
// Title follows the primary (first) tab
const primary = tabs[0]
- if (primary === 'tvshow' && visibleTVSeries.length > 0) {
+ if (primary === 'place' && visiblePlaces.length > 0) {
+ panelTitle.value = visiblePlaces.length === 1 ? visiblePlaces[0].name : `${visiblePlaces.length} Places`
+ } else if (primary === 'tvshow' && visibleTVSeries.length > 0) {
panelTitle.value = visibleTVSeries.length === 1 ? visibleTVSeries[0].title : `${visibleTVSeries.length} TV Series`
} else if (primary === 'book' && visibleBooks.length > 0) {
panelTitle.value = visibleBooks.length === 1 ? visibleBooks[0].title : `${visibleBooks.length} Books`
@@ -1089,6 +1200,8 @@ export function useContentPanel() {
else if (visibleTVSeries.length > 1) panelTitle.value = `${visibleTVSeries.length} TV Series`
else if (visibleSongs.length === 1) panelTitle.value = visibleSongs[0].title
else if (visibleSongs.length > 1) panelTitle.value = `${visibleSongs.length} Songs`
+ else if (visiblePlaces.length === 1) panelTitle.value = visiblePlaces[0].name
+ else if (visiblePlaces.length > 1) panelTitle.value = `${visiblePlaces.length} Places`
else if (visiblePodcasts.length === 1) panelTitle.value = visiblePodcasts[0].title
else if (visiblePodcasts.length > 1) panelTitle.value = `${visiblePodcasts.length} Podcasts`
else if (visibleNews.length > 0) {
@@ -1130,12 +1243,14 @@ export function useContentPanel() {
const websitesLinks = mergeNewsResults(websitesFromMd, boldDomains)
const hasWebsites = websitesLinks.length > 0
const images = extractAllImages(text, userQuery)
- const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, hasNews, hasWebsites, hasMagazine)
+ const places = extractAllPlaces(text, userQuery)
+ 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)
return {
films: tabs.includes('film') ? films : [],
books: tabs.includes('book') ? books : [],
tvSeries: tabs.includes('tvshow') ? tvSeries : [],
images: tabs.includes('image') ? images : [],
+ places: tabs.includes('place') ? places : [],
songs: tabs.includes('song') ? songs : [],
podcasts: tabs.includes('podcast') ? podcasts : [],
newsLinks: tabs.includes('news') ? newsLinks : [],
@@ -1183,8 +1298,15 @@ export function useContentPanel() {
.trim()
}
+ function stripPlaceTags(text: string): string {
+ return text
+ .replace(PLACE_EXT_RE, '')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim()
+ }
+
function stripContentTags(text: string): string {
- return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(stripTVTags(text)))))
+ return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(stripTVTags(stripPlaceTags(text))))))
}
/** Remove markdown links when surfacing as inline cards to avoid duplication */
@@ -1201,9 +1323,12 @@ export function useContentPanel() {
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
+ selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
+ selectedWebsite.value = null
+ selectedMagazineSection.value = null
}
function closeFilmDetail() {
@@ -1215,9 +1340,12 @@ export function useContentPanel() {
selectedFilm.value = null
selectedTVSeries.value = null
selectedImage.value = null
+ selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
+ selectedWebsite.value = null
+ selectedMagazineSection.value = null
}
function closeBookDetail() {
@@ -1230,8 +1358,11 @@ export function useContentPanel() {
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
+ selectedPlace.value = null
selectedPodcast.value = null
selectedArticle.value = null
+ selectedWebsite.value = null
+ selectedMagazineSection.value = null
}
function closeSongDetail() {
@@ -1244,8 +1375,11 @@ export function useContentPanel() {
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
+ selectedPlace.value = null
selectedSong.value = null
selectedArticle.value = null
+ selectedWebsite.value = null
+ selectedMagazineSection.value = null
}
function closePodcastDetail() {
@@ -1258,8 +1392,11 @@ export function useContentPanel() {
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
+ selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
+ selectedWebsite.value = null
+ selectedMagazineSection.value = null
panelOpen.value = true
}
@@ -1267,14 +1404,63 @@ export function useContentPanel() {
selectedArticle.value = null
}
+ function openWebsiteDetail(website: WebSearchResult) {
+ selectedWebsite.value = website
+ selectedFilm.value = null
+ selectedBook.value = null
+ selectedTVSeries.value = null
+ selectedImage.value = null
+ selectedPlace.value = null
+ selectedSong.value = null
+ selectedPodcast.value = null
+ selectedArticle.value = null
+ panelOpen.value = true
+ }
+
+ function closeWebsiteDetail() {
+ selectedWebsite.value = null
+ }
+
+ function openMagazineSectionDetail(section: MagazineSection, index: number) {
+ selectedMagazineSection.value = section
+ magazineSectionIndex.value = index
+ selectedFilm.value = null
+ selectedBook.value = null
+ selectedTVSeries.value = null
+ selectedImage.value = null
+ selectedPlace.value = null
+ selectedSong.value = null
+ selectedPodcast.value = null
+ selectedArticle.value = null
+ selectedWebsite.value = null
+ }
+
+ function closeMagazineSectionDetail() {
+ selectedMagazineSection.value = null
+ }
+
+ function navigateMagazineSection(direction: 'prev' | 'next') {
+ const sections = panelMagazineSections.value
+ if (!sections.length) return
+ let idx = magazineSectionIndex.value
+ idx += direction === 'next' ? 1 : -1
+ if (idx < 0) idx = sections.length - 1
+ if (idx >= sections.length) idx = 0
+ magazineSectionIndex.value = idx
+ selectedMagazineSection.value = sections[idx]
+ }
+
function openTVSeriesDetail(series: TVSeries) {
selectedTVSeries.value = series
selectedFilm.value = null
selectedBook.value = null
selectedImage.value = null
+ selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
+ selectedWebsite.value = null
+ selectedMagazineSection.value = null
}
function closeTVSeriesDetail() {
@@ -1286,24 +1472,47 @@ export function useContentPanel() {
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
+ selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
+ selectedWebsite.value = null
+ selectedMagazineSection.value = null
}
function closeImageDetail() {
selectedImage.value = null
}
+ function openPlaceDetail(place: Place) {
+ selectedPlace.value = place
+ selectedFilm.value = null
+ selectedBook.value = null
+ selectedTVSeries.value = null
+ selectedImage.value = null
+ selectedSong.value = null
+ selectedPodcast.value = null
+ selectedArticle.value = null
+ selectedWebsite.value = null
+ selectedMagazineSection.value = null
+ }
+
+ function closePlaceDetail() {
+ selectedPlace.value = null
+ }
+
function closePanel() {
panelOpen.value = false
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
+ selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
+ selectedWebsite.value = null
+ selectedMagazineSection.value = null
activeTab.value = 'film'
availableTabs.value = []
}
@@ -1319,6 +1528,8 @@ export function useContentPanel() {
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
+ selectedWebsite.value = null
+ selectedMagazineSection.value = null
}
function showAllSongs() {
@@ -1332,6 +1543,8 @@ export function useContentPanel() {
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
+ selectedWebsite.value = null
+ selectedMagazineSection.value = null
}
function showAllPodcasts() {
@@ -1353,6 +1566,7 @@ export function useContentPanel() {
panelBooks,
panelTVSeries,
panelImages,
+ panelPlaces,
panelSongs,
panelPodcasts,
panelWebResults,
@@ -1363,9 +1577,13 @@ export function useContentPanel() {
selectedBook,
selectedTVSeries,
selectedImage,
+ selectedPlace,
selectedSong,
selectedPodcast,
selectedArticle,
+ selectedWebsite,
+ selectedMagazineSection,
+ magazineSectionIndex,
panelTitle,
panelQuery,
contentType,
@@ -1383,6 +1601,8 @@ export function useContentPanel() {
closeTVSeriesDetail,
openImageDetail,
closeImageDetail,
+ openPlaceDetail,
+ closePlaceDetail,
extractSongIds,
resolveSongs,
extractAllSongs,
@@ -1404,6 +1624,11 @@ export function useContentPanel() {
closePodcastDetail,
openArticleDetail,
closeArticleDetail,
+ openWebsiteDetail,
+ closeWebsiteDetail,
+ openMagazineSectionDetail,
+ closeMagazineSectionDetail,
+ navigateMagazineSection,
closePanel,
showAllFilms,
showAllSongs,
diff --git a/packages/app/src/composables/useImageFallback.ts b/packages/app/src/composables/useImageFallback.ts
index 0cb69f0c..4feba650 100644
--- a/packages/app/src/composables/useImageFallback.ts
+++ b/packages/app/src/composables/useImageFallback.ts
@@ -321,6 +321,22 @@ export async function fetchBookCover(
}
}
+/** Place/restaurant fallback — map pin with cuisine hint */
+export function generatePlaceFallback(name: string, cuisine?: string): string {
+ const hue = [...(name + (cuisine ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
+ const svg = ``
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`
+}
+
export async function fetchMusicCover(
title: string,
artist: string,
diff --git a/packages/app/src/pages/ChatPage.vue b/packages/app/src/pages/ChatPage.vue
index c81aee58..b6929b6e 100644
--- a/packages/app/src/pages/ChatPage.vue
+++ b/packages/app/src/pages/ChatPage.vue
@@ -10,20 +10,22 @@
>
-
+
-
+
@@ -56,6 +58,7 @@
:panel-books="panelBooks"
:panelTVSeries="panelTVSeries"
:panel-images="panelImages"
+ :panel-places="panelPlaces"
:panel-songs="panelSongs"
:panel-podcasts="panelPodcasts"
:panel-web-results="panelWebResults"
@@ -68,61 +71,9 @@
/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
@@ -156,10 +107,39 @@
+
+
+
+
+
+
+
+
+ ✦
+
+
+
+ Awaiting Context
+
+
+ Select an item to see details here.
+
+
+
+
+
+