diff --git a/packages/app/src/components/chat/ChatMessage.vue b/packages/app/src/components/chat/ChatMessage.vue
index e2e347cc..7e865174 100644
--- a/packages/app/src/components/chat/ChatMessage.vue
+++ b/packages/app/src/components/chat/ChatMessage.vue
@@ -184,6 +184,22 @@
/>
+
+
+
+
+
+
+
+
{
return extractCashuTokens(props.message.content)
})
+const inlineRecipes = computed(() => {
+ if (isUser.value) return []
+ return extractRecipes(props.message.content)
+})
+
+const inlineEvents = computed(() => {
+ if (isUser.value) return []
+ return extractEvents(props.message.content)
+})
+
const isCodeResponse = computed(() =>
!isUser.value && props.triggeringQuery.trim().toLowerCase() === '/code'
)
diff --git a/packages/app/src/components/renderers/EventCard.vue b/packages/app/src/components/renderers/EventCard.vue
new file mode 100644
index 00000000..ea93dac1
--- /dev/null
+++ b/packages/app/src/components/renderers/EventCard.vue
@@ -0,0 +1,142 @@
+
+
+
+
+
+
{{ monthLabel }}
+
{{ dayLabel }}
+
+
+
+
{{ event.title }}
+
{{ event.location }}
+
{{ event.description }}
+
+
+
+ {{ countdownText }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/components/renderers/RecipeCard.vue b/packages/app/src/components/renderers/RecipeCard.vue
new file mode 100644
index 00000000..40550d1d
--- /dev/null
+++ b/packages/app/src/components/renderers/RecipeCard.vue
@@ -0,0 +1,111 @@
+
+
+
+
+
{{ recipe.title }}
+
+
+
+ {{ recipe.time }}
+
+
+
+ {{ scaledServings }} servings
+
+
{{ recipe.calories }} cal
+
+
+
+
+
+
+
+ {{ scaleFactor }}×
+
+
+
+
+
+
+
+
Steps
+
+ -
+ {{ i + 1 }}
+ {{ step }}
+
+
+
+
+
+
+
diff --git a/packages/app/src/composables/contentExtraction.ts b/packages/app/src/composables/contentExtraction.ts
index 36187a27..a69004fe 100644
--- a/packages/app/src/composables/contentExtraction.ts
+++ b/packages/app/src/composables/contentExtraction.ts
@@ -1063,3 +1063,83 @@ export function stripMarkdownLinks(text: string): string {
.replace(/\n{3,}/g, '\n\n')
.trim()
}
+
+// ─── Recipe extraction (M10.4) ─────────────────────────────────
+
+export interface RecipeData {
+ title: string
+ servings?: string
+ time?: string
+ calories?: string
+ ingredients: string[]
+ steps: string[]
+}
+
+const RECIPE_EXT_RE = /]+)>([\s\S]*?)<\/recipe_ext>/gi
+
+export function extractRecipes(text: string): RecipeData[] {
+ const results: RecipeData[] = []
+ let m: RegExpExecArray | null
+ while ((m = RECIPE_EXT_RE.exec(text)) !== null) {
+ const attrs = m[1]
+ const body = m[2]
+
+ const title = attrs.match(/title="([^"]*)"/)?.[1] || 'Recipe'
+ const servings = attrs.match(/servings="([^"]*)"/)?.[1]
+ const time = attrs.match(/time="([^"]*)"/)?.[1]
+ const calories = attrs.match(/calories="([^"]*)"/)?.[1]
+
+ // Parse body: lines starting with - are ingredients, numbered lines are steps
+ const lines = body.split('\n').map(l => l.trim()).filter(Boolean)
+ const ingredients: string[] = []
+ const steps: string[] = []
+
+ for (const line of lines) {
+ if (/^[-*]\s+/.test(line)) {
+ ingredients.push(line.replace(/^[-*]\s+/, ''))
+ } else if (/^\d+[.)]\s+/.test(line)) {
+ steps.push(line.replace(/^\d+[.)]\s+/, ''))
+ }
+ }
+
+ results.push({ title, servings, time, calories, ingredients, steps })
+ }
+ return results
+}
+
+export function stripRecipeTags(text: string): string {
+ return text.replace(RECIPE_EXT_RE, '').replace(/\n{3,}/g, '\n\n').trim()
+}
+
+// ─── Event extraction (M10.5) ──────────────────────────────────
+
+export interface EventData {
+ title: string
+ date: string
+ location?: string
+ url?: string
+ description?: string
+}
+
+const EVENT_EXT_RE = /]*?)(?:\/>|>([\s\S]*?)<\/event_ext>)/gi
+
+export function extractEvents(text: string): EventData[] {
+ const results: EventData[] = []
+ let m: RegExpExecArray | null
+ while ((m = EVENT_EXT_RE.exec(text)) !== null) {
+ const attrs = m[1]
+ const body = m[2]?.trim()
+
+ const title = attrs.match(/title="([^"]*)"/)?.[1] || 'Event'
+ const date = attrs.match(/date="([^"]*)"/)?.[1] || ''
+ const location = attrs.match(/location="([^"]*)"/)?.[1]
+ const url = attrs.match(/url="([^"]*)"/)?.[1]
+
+ results.push({ title, date, location, url, description: body })
+ }
+ return results
+}
+
+export function stripEventTags(text: string): string {
+ return text.replace(EVENT_EXT_RE, '').replace(/\n{3,}/g, '\n\n').trim()
+}