Files
insta-recipe/src/routes/share/+page.svelte
Giancarmine Salucci da58263aba feat: refactor frontend and fix LLM extraction
- Fix critical await bug in extract-stream endpoint
- Add comprehensive logging to LLM and parser modules
- Implement fallback to standard completion for incompatible models
- Create enhanced v2.0 prompts with social media handling and few-shot examples
- Add LLM health check endpoint
- Decompose share page into 6 focused Svelte 5 snippets

Resolves LM Studio integration issues and improves code maintainability
2025-12-21 03:49:33 +01:00

306 lines
11 KiB
Svelte
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { page } from '$app/stores';
import type { ProgressEvent } from '$lib/server/extraction';
let status = $state('idle');
let logs = $state<string[]>([]);
let recipe = $state<any>(null);
let bodyText = $state<string>('');
let tandoorEnabled = $state(false);
let tandoorImporting = $state(false);
let tandoorError = $state<string | null>(null);
let currentMethod = $state<string>('');
// URL param parsing for Share Target
// Instagram typically shares text that contains the URL, so we might need to parse it out
let sharedText = $derived($page.url.searchParams.get('text') || '');
let sharedUrl = $derived($page.url.searchParams.get('url') || '');
function extractUrl(text: string) {
const match = text.match(/(https?:\/\/[^\s]+)/);
return match ? match[0] : null;
}
let targetUrl = $derived(sharedUrl || extractUrl(sharedText));
$effect.pre(() => {
loadTandoorConfig();
});
// Load Tandoor config on mount
async function loadTandoorConfig() {
try {
const res = await fetch('/api/tandoor-config');
const config = await res.json();
tandoorEnabled = config.enabled;
logs = [...logs, `Tandoor integration ${config.enabled ? 'enabled' : 'disabled'}`];
} catch(e) {
logs = [...logs, 'Failed to load Tandoor config'];
}
}
// Map method names to icons
function getMethodIcon(method?: string): string {
const icons: Record<string, string> = {
'embedded-json': '📦',
'dom-selector': '🎯',
'graphql-api': '🔌',
'legacy': '📄'
};
return method ? icons[method] || '⚙️' : '⚙️';
}
async function process() {
if(!targetUrl) return;
status = 'extracting';
logs = [...logs, '🚀 Starting extraction from: ' + targetUrl];
currentMethod = '';
try {
const response = await fetch('/api/extract-stream', {
method: 'POST',
body: JSON.stringify({ url: targetUrl }),
headers: { 'Content-Type': 'application/json' }
});
if (!response.body) {
throw new Error('No response body');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
const eventMatch = line.match(/^event: (\w+)\ndata: (.+)$/s);
if (!eventMatch) continue;
const [, eventType, eventData] = eventMatch;
const event: ProgressEvent = JSON.parse(eventData);
// Update UI based on event type
if (event.type === 'method') {
currentMethod = event.method || '';
logs = [...logs, `${getMethodIcon(event.method)} ${event.message}`];
} else if (event.type === 'status') {
logs = [...logs, ` ${event.message}`];
} else if (event.type === 'retry') {
logs = [...logs, `🔄 ${event.message}`];
} else if (event.type === 'error') {
logs = [...logs, `❌ ${event.message}`];
} else if (eventType === 'complete' && event.data) {
recipe = event.data.recipe;
bodyText = event.data.recipe?.bodyText || '';
status = 'done';
logs = [...logs, `✅ ${event.message}`];
currentMethod = '';
}
}
}
if (status !== 'done') {
status = 'error';
}
} catch(e) {
logs = [...logs, '❌ Network Error: ' + (e instanceof Error ? e.message : 'Unknown')];
status = 'error';
}
}
async function retry() {
recipe = null;
bodyText = '';
status = 'idle';
logs = [...logs, 'Retrying extraction...'];
await process();
}
async function importToTandoor() {
if (!recipe) return;
tandoorImporting = true;
tandoorError = null;
logs = [...logs, 'Importing recipe to Tandoor...'];
try {
const res = await fetch('/api/tandoor', {
method: 'POST',
body: JSON.stringify({ recipe }),
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (data.success) {
logs = [...logs, `✓ Recipe imported successfully (ID: ${data.recipeId})`];
tandoorError = null;
} else {
logs = [...logs, `✗ Import failed: ${data.error}`];
tandoorError = data.error;
}
} catch(e) {
const errorMsg = e instanceof Error ? e.message : 'Unknown error';
logs = [...logs, `✗ Network error: ${errorMsg}`];
tandoorError = errorMsg;
} finally {
tandoorImporting = false;
}
}
</script>
{#snippet urlInputSection()}
{#if targetUrl}
<div class="bg-gray-100 p-2 rounded break-all text-sm border">{targetUrl}</div>
{#if status === 'idle'}
<button onclick={process} class="bg-blue-600 text-white px-4 py-2 rounded shadow hover:bg-blue-700 w-full">
Extract Recipe
</button>
{/if}
{:else}
<p class="text-gray-500">No URL detected. Open this app via Instagram Share Menu.</p>
<div class="text-xs text-gray-400">Debug: Text={sharedText} URL={sharedUrl}</div>
{/if}
{/snippet}
{#snippet progressIndicator()}
{#if status === 'extracting'}
<div class="animate-pulse text-blue-600">Extracting data...</div>
{/if}
{/snippet}
{#snippet extractedTextViewer()}
{#if bodyText}
<details class="border rounded p-2 bg-white text-sm">
<summary class="cursor-pointer font-semibold">📝 View Extracted Text</summary>
<div class="mt-2 pt-2 border-t whitespace-pre-wrap break-word max-h-48 overflow-y-auto text-xs">
{bodyText}
</div>
</details>
{/if}
{/snippet}
{#snippet recipeCard()}
{#if recipe}
<div class="border rounded p-4 bg-green-50 space-y-2">
<h2 class="font-bold text-xl">{recipe.name}</h2>
<p class="text-sm">{recipe.description}</p>
<p class="text-muted"><strong>Servings:</strong> {recipe.servings}</p>
<h3 class="font-bold mt-2">Ingredients</h3>
<ul class="list-disc pl-5 text-sm">
{#each recipe.ingredients as ing}
<li>{ing.amount} {ing.unit} {ing.item}</li>
{/each}
</ul>
<h3 class="font-bold mt-2">Steps</h3>
<ol class="list-decimal pl-5 text-sm">
{#each recipe.steps as step}
<li>{step}</li>
{/each}
</ol>
{#if tandoorEnabled}
<div class="mt-4 pt-4 border-t space-y-2">
<h3 class="font-bold">Tandoor Integration</h3>
{#if tandoorError}
<div class="bg-red-100 text-red-800 p-2 rounded text-sm">
Error: {tandoorError}
</div>
{/if}
<button
onclick={importToTandoor}
disabled={tandoorImporting}
class="bg-orange-600 text-white px-4 py-2 rounded shadow hover:bg-orange-700 w-full disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{tandoorImporting ? 'Importing...' : 'Import to Tandoor'}
</button>
</div>
{/if}
<button
onclick={retry}
class="bg-blue-500 text-white px-4 py-2 rounded shadow hover:bg-blue-600 w-full mt-2"
>
🔄 Retry Extraction
</button>
</div>
{/if}
{/snippet}
{#snippet errorState()}
{#if status === 'error' && bodyText}
<div class="border rounded p-4 bg-yellow-50 space-y-2">
<h3 class="font-bold text-lg">Extraction Error - Raw Text Available</h3>
<details class="border rounded p-2 bg-white text-sm">
<summary class="cursor-pointer font-semibold">📝 View Extracted Text</summary>
<div class="mt-2 pt-2 border-t whitespace-pre-wrap break-word max-h-48 overflow-y-auto text-xs">
{bodyText}
</div>
</details>
<button
onclick={retry}
class="bg-blue-500 text-white px-4 py-2 rounded shadow hover:bg-blue-600 w-full mt-2"
>
🔄 Retry Extraction
</button>
</div>
{/if}
{/snippet}
{#snippet logViewer()}
<div class="bg-slate-900 text-slate-100 p-4 rounded-lg shadow-lg min-h-[120px] max-h-[400px] overflow-y-auto">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-slate-700">
<div class="text-sm font-semibold opacity-70">System Logs</div>
{#if currentMethod}
<div class="text-xs bg-blue-600 px-2 py-1 rounded flex items-center gap-1">
<span class="animate-pulse"></span>
<span>Current: {currentMethod}</span>
</div>
{/if}
</div>
<div class="space-y-1 font-mono text-xs">
{#each logs as log}
<div class="flex items-start gap-2 py-1 {
log.includes('✅') ? 'text-green-400' :
log.includes('❌') ? 'text-red-400' :
log.includes('🔄') ? 'text-yellow-400' :
log.includes('📦') || log.includes('🎯') || log.includes('🔌') || log.includes('📄') ? 'text-blue-300' :
'text-slate-300'
}">
<span class="opacity-50">&gt;</span>
<span class="flex-1">{log}</span>
</div>
{/each}
{#if status === 'extracting'}
<div class="flex items-center gap-2 py-1 text-blue-400 animate-pulse">
<span class="opacity-50">&gt;</span>
<span>Processing...</span>
</div>
{/if}
</div>
</div>
{/snippet}
<div class="p-8 max-w-lg mx-auto space-y-4">
<h1 class="text-2xl font-bold">InstaChef PWA</h1>
{@render urlInputSection()}
{@render progressIndicator()}
{@render extractedTextViewer()}
{@render recipeCard()}
{@render errorState()}
{@render logViewer()}
</div>