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
This commit is contained in:
@@ -2,11 +2,42 @@ import OpenAI from 'openai';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
export const createLLM = () => {
|
||||
// Detect if we are using Ollama or OpenAI based on URL
|
||||
const baseURL = env.OPENAI_BASE_URL;
|
||||
const client = new OpenAI({
|
||||
apiKey: env.OPENAI_API_KEY,
|
||||
baseURL: baseURL
|
||||
});
|
||||
return { client, model: env.LLM_MODEL || 'gpt-4o' };
|
||||
};
|
||||
// Detect if we are using Ollama or OpenAI based on URL
|
||||
const baseURL = env.OPENAI_BASE_URL;
|
||||
const apiKey = env.OPENAI_API_KEY;
|
||||
const model = env.LLM_MODEL || 'gpt-4o';
|
||||
|
||||
console.log('[LLM] Initializing client...');
|
||||
console.log('[LLM] Base URL:', baseURL);
|
||||
console.log('[LLM] Model:', model);
|
||||
|
||||
if (!baseURL) {
|
||||
throw new Error('OPENAI_BASE_URL environment variable is not set');
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('OPENAI_API_KEY environment variable is not set');
|
||||
}
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey,
|
||||
baseURL
|
||||
});
|
||||
|
||||
return { client, model };
|
||||
};
|
||||
|
||||
/**
|
||||
* Health check for LLM service
|
||||
*/
|
||||
export async function checkLLMHealth(): Promise<boolean> {
|
||||
try {
|
||||
const { client } = createLLM();
|
||||
await client.models.list();
|
||||
console.log('[LLM] Health check passed');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[LLM] Health check failed:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createLLM } from './llm';
|
||||
import { zodResponseFormat } from 'openai/helpers/zod';
|
||||
import { z } from 'zod';
|
||||
import { RECIPE_DETECTION_PROMPT, RECIPE_EXTRACTION_PROMPT } from './prompts/recipe-extraction';
|
||||
|
||||
const RecipeSchema = z.object({
|
||||
name: z.string(),
|
||||
@@ -28,27 +29,34 @@ export async function detectRecipe(text: string): Promise<boolean> {
|
||||
try {
|
||||
const { client, model } = createLLM();
|
||||
|
||||
console.log('[LLM] Starting recipe detection...');
|
||||
console.log('[LLM] Model:', model);
|
||||
console.log('[LLM] Text length:', text.length);
|
||||
|
||||
const detectionResponse = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
"You are a recipe detector. Answer with ONLY 'yes' or 'no' - nothing else. A recipe MUST have: (1) name/title, (2) ingredients with quantities, (3) numbered cooking steps. If ANY are missing, answer 'no'."
|
||||
content: RECIPE_DETECTION_PROMPT
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Does this text contain a recipe?\n\n${text}`
|
||||
}
|
||||
],
|
||||
max_tokens: 10
|
||||
max_tokens: 10,
|
||||
temperature: 0
|
||||
});
|
||||
|
||||
const detectionResult = detectionResponse.choices[0].message.content?.toLowerCase() ?? '';
|
||||
console.log('[LLM] Detection response:', detectionResult);
|
||||
|
||||
return detectionResult.includes('yes');
|
||||
} catch (e) {
|
||||
console.error('Recipe detection error:', e);
|
||||
throw new Error('Failed to detect recipe');
|
||||
console.error('[LLM] Recipe detection error:', e);
|
||||
console.error('[LLM] Stack trace:', (e as Error).stack);
|
||||
throw new Error(`Failed to detect recipe: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,47 +69,27 @@ export async function parseRecipe(text: string): Promise<Recipe> {
|
||||
try {
|
||||
const { client, model } = createLLM();
|
||||
|
||||
console.log('[LLM] Starting recipe parsing...');
|
||||
console.log('[LLM] Model:', model);
|
||||
|
||||
const completion = await client.beta.chat.completions.parse({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a RECIPE EXTRACTOR. Extract the recipe from the provided text.
|
||||
|
||||
✅ REQUIREMENTS:
|
||||
1. Extract the exact recipe name from the text
|
||||
2. List all ingredients with their quantities and units
|
||||
3. List all cooking steps in order
|
||||
4. Translate everything to Italian
|
||||
5. Convert measurements to SI units (g, mL, °C)
|
||||
|
||||
📋 CONVERSION TABLE:
|
||||
- 1 cup = 240 mL, 1 tbsp = 15 mL, 1 tsp = 5 mL
|
||||
- 1 oz = 28.35 g, 1 lb = 453.59 g
|
||||
- 1 stick butter = 113 g
|
||||
- °F→°C: (°F–32)×5/9
|
||||
|
||||
🔄 OUTPUT FORMAT:
|
||||
{
|
||||
"name": "recipe name in Italian",
|
||||
"servings": number or null,
|
||||
"description": "description in Italian or null",
|
||||
"ingredients": [{"item": "ingredient name", "amount": "quantity", "unit": "SI unit"}],
|
||||
"steps": ["1. First step", "2. Second step", ...]
|
||||
}
|
||||
|
||||
Extract ONLY what's explicitly in the text. Be accurate and literal.
|
||||
`
|
||||
content: RECIPE_EXTRACTION_PROMPT
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Extract the recipe from this text:\n\n${text}`
|
||||
}
|
||||
],
|
||||
response_format: zodResponseFormat(RecipeSchema, 'recipe')
|
||||
response_format: zodResponseFormat(RecipeSchema, 'recipe'),
|
||||
temperature: 0.3
|
||||
});
|
||||
|
||||
const recipe = completion.choices[0].message.parsed;
|
||||
console.log('[LLM] Parse response:', recipe?.name);
|
||||
|
||||
if (!recipe || !recipe.name) {
|
||||
throw new Error('Failed to extract recipe - missing name');
|
||||
@@ -109,8 +97,17 @@ Extract ONLY what's explicitly in the text. Be accurate and literal.
|
||||
|
||||
return recipe;
|
||||
} catch (e) {
|
||||
console.error('Recipe parsing error:', e);
|
||||
throw new Error('Failed to parse recipe');
|
||||
console.error('[LLM] Recipe parsing error:', e);
|
||||
console.error('[LLM] Stack trace:', (e as Error).stack);
|
||||
|
||||
// If structured output fails, try standard completion
|
||||
if ((e as any).message?.includes('response_format') ||
|
||||
(e as any).message?.includes('structured output')) {
|
||||
console.warn('[LLM] Falling back to standard completion');
|
||||
return await parseRecipeWithStandardCompletion(text);
|
||||
}
|
||||
|
||||
throw new Error(`Failed to parse recipe: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,3 +125,56 @@ export async function extractRecipe(text: string): Promise<Recipe | null> {
|
||||
|
||||
return parseRecipe(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback parser using standard completion (no structured output)
|
||||
* Used when the model doesn't support beta.chat.completions.parse()
|
||||
*/
|
||||
async function parseRecipeWithStandardCompletion(text: string): Promise<Recipe> {
|
||||
const { client, model } = createLLM();
|
||||
|
||||
console.log('[LLM] Using standard completion fallback');
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a recipe extractor. Return ONLY valid JSON matching this schema:
|
||||
{
|
||||
"name": "recipe name in Italian",
|
||||
"servings": number or null,
|
||||
"description": "description in Italian or null",
|
||||
"ingredients": [{"item": "ingredient name", "amount": "quantity", "unit": "SI unit"}],
|
||||
"steps": ["1. First step", "2. Second step", ...]
|
||||
}
|
||||
|
||||
Convert all measurements to SI units (g, mL, °C).
|
||||
Translate everything to Italian.
|
||||
Extract ONLY what's in the text.`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Extract the recipe from this text:\n\n${text}`
|
||||
}
|
||||
],
|
||||
max_tokens: 2000,
|
||||
temperature: 0.3
|
||||
});
|
||||
|
||||
const jsonResponse = completion.choices[0].message.content;
|
||||
if (!jsonResponse) {
|
||||
throw new Error('Empty response from LLM');
|
||||
}
|
||||
|
||||
console.log('[LLM] Standard completion raw response:', jsonResponse.substring(0, 200));
|
||||
|
||||
// Parse and validate JSON (remove code fences if present)
|
||||
const cleanedJson = jsonResponse.replace(/```json\n?|```\n?/g, '').trim();
|
||||
const parsedData = JSON.parse(cleanedJson);
|
||||
const recipe = RecipeSchema.parse(parsedData);
|
||||
|
||||
console.log('[LLM] Standard completion parsed recipe:', recipe.name);
|
||||
|
||||
return recipe;
|
||||
}
|
||||
|
||||
220
src/lib/server/prompts/recipe-extraction.ts
Normal file
220
src/lib/server/prompts/recipe-extraction.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Recipe Extraction System Prompts - Version 2.0
|
||||
*
|
||||
* Changelog:
|
||||
* - v2.0 (2025-12-21): Added social media handling, few-shot examples, partial recipe support
|
||||
* - v1.0 (2024): Initial version with Italian translation and SI conversion
|
||||
*/
|
||||
|
||||
export const RECIPE_DETECTION_PROMPT = `You are a recipe detector for social media posts.
|
||||
|
||||
Your task: Determine if the text contains a complete or partial recipe.
|
||||
|
||||
REQUIREMENTS FOR "YES":
|
||||
1. Recipe name/title is present
|
||||
2. At least 3 ingredients with quantities (even if approximate)
|
||||
3. At least 2 cooking steps
|
||||
|
||||
IGNORE:
|
||||
- Hashtags (#recipe, #food, etc.)
|
||||
- Mentions (@username)
|
||||
- Emojis
|
||||
- Like counts, comments, social metadata
|
||||
- Promotional text
|
||||
|
||||
OUTPUT: Answer with ONLY 'yes' or 'no' - nothing else.
|
||||
|
||||
EXAMPLES:
|
||||
|
||||
Text: "🍝 Pasta al Pomodoro 🍅 Ingredients: 320g pasta, 400g tomatoes, 2 garlic cloves. Boil pasta. Sauté garlic. Add tomatoes. Mix! #italianfood @chef"
|
||||
Answer: yes
|
||||
|
||||
Text: "Amazing dinner tonight! 😍 So delicious! 🔥 #foodporn"
|
||||
Answer: no
|
||||
|
||||
Text: "You need pasta, tomatoes, and garlic for this recipe"
|
||||
Answer: no (missing steps)
|
||||
`;
|
||||
|
||||
export const RECIPE_EXTRACTION_PROMPT = `You are an EXPERT RECIPE EXTRACTOR specialized in parsing recipes from social media posts.
|
||||
|
||||
🎯 YOUR MISSION:
|
||||
Extract structured recipe data from text that may contain social media noise, emojis, hashtags, and promotional content.
|
||||
|
||||
✅ CORE REQUIREMENTS:
|
||||
|
||||
1. **Text Cleaning**: Ignore hashtags, mentions, emojis, like counts, promotional text
|
||||
2. **Name Extraction**: Extract exact recipe name (translate to Italian)
|
||||
3. **Ingredient Parsing**: Extract all ingredients with quantities and units
|
||||
4. **Step Extraction**: Extract all cooking steps in order
|
||||
5. **Translation**: Translate ALL content to Italian
|
||||
6. **Unit Conversion**: Convert ALL measurements to SI units (g, mL, °C)
|
||||
|
||||
📏 COMPREHENSIVE CONVERSION TABLE:
|
||||
|
||||
**Volume (to mL):**
|
||||
- 1 cup = 240 mL
|
||||
- 1 tablespoon (tbsp) = 15 mL
|
||||
- 1 teaspoon (tsp) = 5 mL
|
||||
- 1 fluid oz (fl oz) = 30 mL
|
||||
- 1 pint = 473 mL
|
||||
- 1 quart = 946 mL
|
||||
- 1 gallon = 3785 mL
|
||||
|
||||
**Weight (to g):**
|
||||
- 1 oz = 28.35 g
|
||||
- 1 lb (pound) = 453.59 g
|
||||
- 1 stick butter = 113 g
|
||||
|
||||
**Temperature (to °C):**
|
||||
- Formula: (°F - 32) × 5/9
|
||||
- 350°F = 175°C
|
||||
- 375°F = 190°C
|
||||
- 400°F = 200°C
|
||||
- 425°F = 220°C
|
||||
|
||||
**Special Cases:**
|
||||
- "a pinch" = "un pizzico" (no quantity)
|
||||
- "to taste" = "q.b." (quanto basta)
|
||||
- "1-2 cups" → use midpoint → 1.5 cup = 360 mL
|
||||
- "1/2 cup" = 120 mL
|
||||
- "1/4 cup" = 60 mL
|
||||
|
||||
🔄 OUTPUT FORMAT (JSON):
|
||||
|
||||
{
|
||||
"name": "Nome della Ricetta in Italiano",
|
||||
"servings": 4 or null,
|
||||
"description": "Descrizione in italiano o null",
|
||||
"ingredients": [
|
||||
{"item": "nome ingrediente", "amount": "quantità", "unit": "unità SI"},
|
||||
{"item": "aglio", "amount": "2", "unit": "spicchi"}
|
||||
],
|
||||
"steps": [
|
||||
"1. Primo passaggio dettagliato",
|
||||
"2. Secondo passaggio dettagliato"
|
||||
]
|
||||
}
|
||||
|
||||
🎓 FEW-SHOT EXAMPLES:
|
||||
|
||||
**Example 1: Clean Recipe**
|
||||
|
||||
Input:
|
||||
"Chocolate Chip Cookies
|
||||
|
||||
Ingredients:
|
||||
- 2 cups all-purpose flour
|
||||
- 1 tsp baking soda
|
||||
- 1 cup butter
|
||||
- 3/4 cup sugar
|
||||
- 2 eggs
|
||||
- 2 cups chocolate chips
|
||||
|
||||
Instructions:
|
||||
1. Preheat oven to 375°F
|
||||
2. Mix flour and baking soda
|
||||
3. Cream butter and sugar
|
||||
4. Add eggs
|
||||
5. Fold in chocolate chips
|
||||
6. Bake for 10 minutes"
|
||||
|
||||
Output:
|
||||
{
|
||||
"name": "Biscotti con Gocce di Cioccolato",
|
||||
"servings": null,
|
||||
"description": null,
|
||||
"ingredients": [
|
||||
{"item": "farina 00", "amount": "480", "unit": "mL"},
|
||||
{"item": "bicarbonato di sodio", "amount": "5", "unit": "mL"},
|
||||
{"item": "burro", "amount": "240", "unit": "mL"},
|
||||
{"item": "zucchero", "amount": "180", "unit": "mL"},
|
||||
{"item": "uova", "amount": "2", "unit": "pz"},
|
||||
{"item": "gocce di cioccolato", "amount": "480", "unit": "mL"}
|
||||
],
|
||||
"steps": [
|
||||
"1. Preriscaldare il forno a 190°C",
|
||||
"2. Mescolare farina e bicarbonato di sodio",
|
||||
"3. Montare burro e zucchero a crema",
|
||||
"4. Aggiungere le uova",
|
||||
"5. Incorporare le gocce di cioccolato",
|
||||
"6. Cuocere per 10 minuti"
|
||||
]
|
||||
}
|
||||
|
||||
**Example 2: Social Media Post**
|
||||
|
||||
Input:
|
||||
"🍝 OMG this pasta is AMAZING! 😍👌
|
||||
|
||||
Farfalle al Salmone by @lulugargari 🔥
|
||||
|
||||
What you need:
|
||||
Farfalle 320g
|
||||
Smoked salmon 200g
|
||||
Heavy cream 200g
|
||||
Shallot 1/2
|
||||
Tomato paste 1 tbsp
|
||||
White wine 1/2 cup
|
||||
Butter 20g
|
||||
Salt & pepper to taste
|
||||
|
||||
How to make it:
|
||||
Chop the salmon. Melt butter, add shallot, cook a bit. Deglaze with wine, add salmon, cook 2 mins. Add cream, pepper, tomato paste. Cook pasta al dente, finish in pan. Enjoy! 😋
|
||||
|
||||
14K likes 🔥 #pasta #recipe #italianfood"
|
||||
|
||||
Output:
|
||||
{
|
||||
"name": "Farfalle al Salmone",
|
||||
"servings": null,
|
||||
"description": null,
|
||||
"ingredients": [
|
||||
{"item": "farfalle", "amount": "320", "unit": "g"},
|
||||
{"item": "salmone affumicato", "amount": "200", "unit": "g"},
|
||||
{"item": "panna fresca liquida", "amount": "200", "unit": "g"},
|
||||
{"item": "scalogno", "amount": "0.5", "unit": "pz"},
|
||||
{"item": "concentrato di pomodoro", "amount": "15", "unit": "mL"},
|
||||
{"item": "vino bianco", "amount": "120", "unit": "mL"},
|
||||
{"item": "burro", "amount": "20", "unit": "g"},
|
||||
{"item": "sale", "amount": "q.b.", "unit": ""},
|
||||
{"item": "pepe nero", "amount": "q.b.", "unit": ""}
|
||||
],
|
||||
"steps": [
|
||||
"1. Tritare il salmone affumicato",
|
||||
"2. Sciogliere il burro e aggiungere lo scalogno tritato, far andare per qualche minuto",
|
||||
"3. Sfumare con il vino e aggiungere il salmone, cuocere un paio di minuti",
|
||||
"4. Aggiungere la panna, il pepe e il concentrato di pomodoro",
|
||||
"5. Cuocere la pasta al dente e ultimare la cottura in padella"
|
||||
]
|
||||
}
|
||||
|
||||
🛡️ EDGE CASE HANDLING:
|
||||
|
||||
1. **Missing Servings**: Set to null
|
||||
2. **Missing Description**: Set to null
|
||||
3. **Ingredient Ranges** (e.g., "1-2 cups"): Use midpoint
|
||||
4. **Vague Quantities** ("a handful"): Use "q.b." and empty unit
|
||||
5. **Missing Units**: Infer from context (e.g., "2 eggs" → "2 pz")
|
||||
6. **Multiple Recipes**: Extract ONLY the first recipe
|
||||
7. **Incomplete Recipe**: Extract what's available, set missing fields to null or empty array
|
||||
|
||||
⚠️ CRITICAL RULES:
|
||||
|
||||
- Extract ONLY what's explicitly in the text - DO NOT invent ingredients or steps
|
||||
- Be LITERAL and ACCURATE - preserve ingredient names and quantities
|
||||
- IGNORE all social media metadata (likes, comments, emojis, hashtags, mentions)
|
||||
- If units are missing, use context clues or standard assumptions
|
||||
- Translate faithfully to Italian, preserving culinary terms accurately
|
||||
- Number all steps sequentially starting with "1."
|
||||
|
||||
🎯 QUALITY CHECKLIST:
|
||||
|
||||
Before returning, verify:
|
||||
- [ ] All ingredients have item, amount, and unit
|
||||
- [ ] All measurements converted to SI units (g, mL, °C)
|
||||
- [ ] All text translated to Italian
|
||||
- [ ] All steps numbered sequentially
|
||||
- [ ] No social media noise (emojis, hashtags, mentions) in output
|
||||
- [ ] JSON is valid and matches schema
|
||||
`;
|
||||
@@ -40,7 +40,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const recipe = extractRecipe(extracted.bodyText);
|
||||
const recipe = await extractRecipe(extracted.bodyText);
|
||||
|
||||
// Send final result
|
||||
const completeEvent: ProgressEvent = {
|
||||
|
||||
30
src/routes/api/llm-health/+server.ts
Normal file
30
src/routes/api/llm-health/+server.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { checkLLMHealth } from '$lib/server/llm';
|
||||
|
||||
/**
|
||||
* Health check endpoint for LLM service
|
||||
* Tests connectivity to LM Studio or OpenAI-compatible endpoint
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const isHealthy = await checkLLMHealth();
|
||||
|
||||
if (isHealthy) {
|
||||
return json({
|
||||
status: 'healthy',
|
||||
message: 'LLM service is accessible'
|
||||
});
|
||||
} else {
|
||||
return json({
|
||||
status: 'unhealthy',
|
||||
message: 'LLM service is not accessible'
|
||||
}, { status: 503 });
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return json({
|
||||
status: 'error',
|
||||
message: errorMessage
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -159,9 +159,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-8 max-w-lg mx-auto space-y-4">
|
||||
<h1 class="text-2xl font-bold">InstaChef PWA</h1>
|
||||
|
||||
{#snippet urlInputSection()}
|
||||
{#if targetUrl}
|
||||
<div class="bg-gray-100 p-2 rounded break-all text-sm border">{targetUrl}</div>
|
||||
|
||||
@@ -174,11 +172,15 @@
|
||||
<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>
|
||||
@@ -187,19 +189,22 @@
|
||||
</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}
|
||||
@@ -233,7 +238,9 @@
|
||||
</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>
|
||||
@@ -251,7 +258,9 @@
|
||||
</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>
|
||||
@@ -283,4 +292,15 @@
|
||||
{/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>
|
||||
Reference in New Issue
Block a user