From 7a5d4b9846549d16623221ddfb5be76e6a573a63 Mon Sep 17 00:00:00 2001 From: Julian Dice <19397727+windoze95@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:44:55 -0500 Subject: [PATCH] feat: improve recipe imports and connector cooking --- internal/handlers/import.go | 3 + internal/mcpserver/server.go | 67 ++++-- internal/mcpserver/tools.go | 59 ++++- internal/mcpserver/widget/app.html | 299 +++++++++++++++++++++--- internal/models/canonical_recipe.go | 1 + internal/repository/canonical_recipe.go | 2 +- internal/service/import.go | 78 ++++++- internal/service/import_service_test.go | 41 +++- 8 files changed, 489 insertions(+), 61 deletions(-) diff --git a/internal/handlers/import.go b/internal/handlers/import.go index f4378c5..36eefa6 100644 --- a/internal/handlers/import.go +++ b/internal/handlers/import.go @@ -615,6 +615,9 @@ func (h *ImportHandler) PreviewFromURL(c *gin.Context) { } response := gin.H{"recipe": result.Recipe} + if result.ImageURL != "" { + response["image_url"] = result.ImageURL + } if result.CanonicalID != nil { response["canonical_id"] = *result.CanonicalID } diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 2e77ae0..dcc9721 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -13,15 +13,21 @@ import ( "github.com/windoze95/saltybytes-api/internal/config" ) -// mcpAppMIMEType is the MCP Apps standard mime type for UI resources. -const mcpAppMIMEType = "text/html;profile=mcp-app" +const ( + // mcpAppMIMEType is the MCP Apps standard MIME type for UI resources. + mcpAppMIMEType = "text/html;profile=mcp-app" + // ChatGPT discovers widgets through an output template using the Apps SDK + // skybridge MIME type. The HTML is shared; the resource contracts are not. + chatGPTMIMEType = "text/html+skybridge" + chatGPTWidgetURI = "ui://saltybytes/chatgpt.html" +) //go:embed widget/app.html var widgetHTML string // serverInstructions is surfaced to connected MCP hosts to guide tool use. const serverInstructions = `SaltyBytes finds REAL recipes from around the web and manages the user's saved recipe collection. -Typical flow: search_recipes to find candidates -> preview_recipe on the chosen result -> save_recipe when the user wants to keep it. +Typical flow: search_recipes to find candidates -> preview_recipe on the chosen result -> save_recipe when the user wants to keep it -> start_cooking when they are ready. Every tool renders an interactive widget in the conversation; prefer letting the widget present recipe details instead of restating them in text.` // widgetResourceMeta declares the widget's MCP Apps metadata (CSP etc.). @@ -45,27 +51,46 @@ func widgetResourceMeta(cfg *config.Config) mcp.Meta { }} } +func chatGPTResourceMeta(cfg *config.Config) mcp.Meta { + resourceDomains := []string{ + fmt.Sprintf("https://%s.s3.amazonaws.com", cfg.EnvVars.S3Bucket), + fmt.Sprintf("https://%s.s3.%s.amazonaws.com", cfg.EnvVars.S3Bucket, cfg.EnvVars.AWSRegion), + } + return mcp.Meta{ + "openai/widgetDescription": "Browse, save, and cook SaltyBytes recipes without leaving the conversation.", + "openai/widgetPrefersBorder": true, + "openai/widgetDomain": cfg.EnvVars.SiteBaseURL, + "openai/widgetCSP": map[string]any{ + "connect_domains": []string{}, + "resource_domains": resourceDomains, + }, + } +} + // registerWidget registers the single MCP Apps UI resource that renders all // tool results. func registerWidget(server *mcp.Server, cfg *config.Config) { - meta := widgetResourceMeta(cfg) - server.AddResource(&mcp.Resource{ - URI: widgetURI, - Name: "saltybytes-app", - Title: "SaltyBytes recipe browser", - Description: "Interactive recipe cards for search results, previews, and the user's saved collection.", - MIMEType: mcpAppMIMEType, - Meta: meta, - }, func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { - return &mcp.ReadResourceResult{ - Contents: []*mcp.ResourceContents{{ - URI: widgetURI, - MIMEType: mcpAppMIMEType, - Text: widgetHTML, - Meta: meta, - }}, - }, nil - }) + register := func(uri, name, mimeType string, meta mcp.Meta) { + server.AddResource(&mcp.Resource{ + URI: uri, + Name: name, + Title: "SaltyBytes recipe browser and cook mode", + Description: "Interactive recipe cards for search, previews, saved recipes, and focused cooking.", + MIMEType: mimeType, + Meta: meta, + }, func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: uri, + MIMEType: mimeType, + Text: widgetHTML, + Meta: meta, + }}, + }, nil + }) + } + register(widgetURI, "saltybytes-app", mcpAppMIMEType, widgetResourceMeta(cfg)) + register(chatGPTWidgetURI, "saltybytes-chatgpt", chatGPTMIMEType, chatGPTResourceMeta(cfg)) } // BuildServer constructs the MCP server with all tools and widgets registered. diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go index f4f527f..a91a39d 100644 --- a/internal/mcpserver/tools.go +++ b/internal/mcpserver/tools.go @@ -27,6 +27,7 @@ const ( viewRecipeCard = "recipe_card" viewPreview = "preview" viewRecipeList = "recipe_list" + viewCookMode = "cook_mode" ) // Deps carries the service-layer dependencies for the MCP tools. The tools @@ -85,6 +86,8 @@ func textResult(summary string) *mcp.CallToolResult { func toolMeta(description, invoking, invoked string) mcp.Meta { return mcp.Meta{ "ui": map[string]any{"resourceUri": widgetURI}, + "openai/outputTemplate": chatGPTWidgetURI, + "openai/widgetAccessible": true, "openai/widgetDescription": description, "openai/toolInvocation/invoking": invoking, "openai/toolInvocation/invoked": invoked, @@ -170,6 +173,7 @@ type previewRecipeIn struct { type previewRecipeOut struct { View string `json:"view"` SourceURL string `json:"source_url"` + ImageURL string `json:"image_url,omitempty"` Recipe *models.RecipeDef `json:"recipe,omitempty"` CanonicalID *uint `json:"canonical_id,omitempty"` IsMulti bool `json:"is_multi"` @@ -191,6 +195,7 @@ func (d *Deps) previewRecipe(ctx context.Context, req *mcp.CallToolRequest, in p return nil, out, fmt.Errorf("could not extract a recipe from that page — the site may be blocking access; try another result") } out.Recipe = preview.Recipe + out.ImageURL = preview.ImageURL out.CanonicalID = preview.CanonicalID out.IsMulti = preview.IsMulti out.Recipes = preview.MultiCards @@ -311,7 +316,8 @@ type getRecipeOut struct { func (d *Deps) getRecipe(ctx context.Context, req *mcp.CallToolRequest, in getRecipeIn) (*mcp.CallToolResult, getRecipeOut, error) { out := getRecipeOut{View: viewRecipeCard, Saved: true} - if _, err := d.userForRequest(req, "recipes:read"); err != nil { + user, err := d.userForRequest(req, "recipes:read") + if err != nil { return nil, out, err } id, err := strconv.ParseUint(strings.TrimSpace(in.RecipeID), 10, 64) @@ -319,7 +325,7 @@ func (d *Deps) getRecipe(ctx context.Context, req *mcp.CallToolRequest, in getRe return nil, out, fmt.Errorf("recipe_id must be a numeric id") } recipe, err := d.Recipes.GetRecipeByID(uint(id)) - if err != nil { + if err != nil || recipe.OwnerID != strconv.FormatUint(uint64(user.ID), 10) { return nil, out, fmt.Errorf("recipe %s not found", in.RecipeID) } out.Recipe = recipe @@ -327,6 +333,39 @@ func (d *Deps) getRecipe(ctx context.Context, req *mcp.CallToolRequest, in getRe recipe.Title, len(recipe.Ingredients), len(recipe.Instructions), recipe.CookTimeMinutes)), out, nil } +// --- start_cooking --- + +type startCookingIn struct { + RecipeID string `json:"recipe_id" jsonschema:"the saved SaltyBytes recipe id to cook"` +} + +type startCookingOut struct { + View string `json:"view"` + Recipe *service.RecipeResponse `json:"recipe,omitempty"` + CurrentStep int `json:"current_step"` +} + +func (d *Deps) startCooking(ctx context.Context, req *mcp.CallToolRequest, in startCookingIn) (*mcp.CallToolResult, startCookingOut, error) { + out := startCookingOut{View: viewCookMode} + user, err := d.userForRequest(req, "recipes:read") + if err != nil { + return nil, out, err + } + id, err := strconv.ParseUint(strings.TrimSpace(in.RecipeID), 10, 64) + if err != nil { + return nil, out, fmt.Errorf("recipe_id must be a numeric id") + } + recipe, err := d.Recipes.GetRecipeByID(uint(id)) + if err != nil || recipe.OwnerID != strconv.FormatUint(uint64(user.ID), 10) { + return nil, out, fmt.Errorf("recipe %s not found", in.RecipeID) + } + if len(recipe.Instructions) == 0 { + return nil, out, fmt.Errorf("%q has no cooking instructions yet", recipe.Title) + } + out.Recipe = recipe + return textResult(fmt.Sprintf("Starting cook mode for %q at step 1 of %d. The interactive card keeps the user focused on one instruction at a time.", recipe.Title, len(recipe.Instructions))), out, nil +} + // registerTools adds every SaltyBytes tool (with its MCP Apps widget // declaration) to the server. func registerTools(server *mcp.Server, deps *Deps) { @@ -415,4 +454,20 @@ func registerTools(server *mcp.Server, deps *Deps) { OpenWorldHint: boolPtr(false), }, }, deps.getRecipe) + + mcp.AddTool(server, &mcp.Tool{ + Name: "start_cooking", + Title: "Start cooking a saved recipe", + Description: "Open a saved SaltyBytes recipe in focused cook mode with one instruction at a time. Call this when the user says they are ready to cook, asks to start cooking, or wants step-by-step guidance for a saved recipe.", + Meta: toolMeta( + "A focused one-step-at-a-time cooking guide with ingredients and progress.", + "Setting up cook mode...", + "Ready to cook", + ), + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + DestructiveHint: boolPtr(false), + OpenWorldHint: boolPtr(false), + }, + }, deps.startCooking) } diff --git a/internal/mcpserver/widget/app.html b/internal/mcpserver/widget/app.html index eceaf9e..99f013e 100644 --- a/internal/mcpserver/widget/app.html +++ b/internal/mcpserver/widget/app.html @@ -254,6 +254,56 @@ .multirow .n { font: 700 16px var(--serif); color: var(--salmon); min-width: 18px; text-align: right; } .multirow .t { font: 600 13.5px var(--serif); } .multirow .d { font: 400 11.5px var(--sans); color: var(--ink-soft); } + + #root { position: relative; } + .busy-overlay { + position: absolute; inset: 0; z-index: 20; + display: flex; align-items: center; justify-content: center; gap: 9px; + min-height: 84px; border-radius: 14px; + background: color-mix(in srgb, var(--paper) 88%, transparent); + backdrop-filter: blur(3px); + color: var(--ink-soft); font: 650 12.5px var(--sans); + } + .busy-dot { + width: 15px; height: 15px; border-radius: 50%; + border: 2px solid var(--line); border-top-color: var(--teal); + animation: spin .75s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } + .cookbtn { background: var(--salmon); } + .cook-shell { + min-height: 410px; display: flex; flex-direction: column; + background: var(--card); border: 1px solid var(--line); + border-radius: 16px; box-shadow: var(--shadow-lift); overflow: hidden; + } + .cook-head { padding: 18px 20px 14px; border-bottom: 1px solid var(--line); } + .cook-head h2 { font: 700 clamp(21px, 4vw, 29px)/1.1 var(--serif); margin: 0 0 8px; } + .cook-progress { height: 5px; border-radius: 999px; background: var(--paper-deep); overflow: hidden; } + .cook-progress > span { display: block; height: 100%; width: 0; background: var(--teal); transition: width .25s ease; } + .cook-stage { min-height: 250px; flex: 1; padding: 22px 20px; } + .cook-count { color: var(--salmon); font: 750 11px var(--sans); letter-spacing: .14em; text-transform: uppercase; } + .cook-instruction { margin-top: 16px; font: 650 clamp(20px, 4.4vw, 30px)/1.35 var(--serif); } + .cook-ingredients { max-height: 230px; overflow: auto; } + .cook-ingredients[hidden], .cook-step[hidden] { display: none; } + .cook-controls { + display: grid; grid-template-columns: auto 1fr auto; gap: 9px; + padding: 14px 20px 18px; border-top: 1px solid var(--line); + } + .cook-control { + border: 1px solid var(--line); border-radius: 999px; padding: 10px 15px; + background: var(--paper); color: var(--ink); cursor: pointer; + font: 700 12.5px var(--sans); + } + .cook-control.primary { color: white; background: var(--teal); border-color: var(--teal); } + .cook-control:disabled { opacity: .38; cursor: default; } + @media (max-width: 460px) { + .cook-shell { min-height: 430px; } + .cook-controls { grid-template-columns: 1fr 1fr; } + .cook-control.ingredients-toggle { grid-column: 1 / -1; grid-row: 1; } + } + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; } + } @@ -308,6 +358,31 @@ * DOM helpers — data is third-party; only ever assign textContent. * ------------------------------------------------------------------ */ const root = document.getElementById("root"); +let visibleResultKey = ""; +let hasCommittedView = false; +let navigationSequence = 0; +let inlineToolDepth = 0; +let ignoreHostResultsUntil = 0; +let ignoredHostResultKey = ""; + +function resultKey(data) { + try { return JSON.stringify(data); } catch { return ""; } +} +function commitResult(data, source) { + if (!data || typeof data !== "object" || !data.view) return false; + const key = resultKey(data); + if (source === "host") { + if (inlineToolDepth > 0 && Date.now() < ignoreHostResultsUntil) return false; + if (key && key === ignoredHostResultKey && Date.now() < ignoreHostResultsUntil) return false; + } + if (key && key === visibleResultKey) { + setBusy(false); + return false; + } + setBusy(false); + render(data); + return true; +} function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; @@ -315,6 +390,20 @@ return node; } function clearRoot() { root.textContent = ""; } +function setBusy(busy, label) { + const existing = root.querySelector(".busy-overlay"); + if (!busy) { + if (existing) existing.remove(); + root.removeAttribute("aria-busy"); + return; + } + if (existing) return; + root.setAttribute("aria-busy", "true"); + const overlay = el("div", "busy-overlay"); + overlay.appendChild(el("span", "busy-dot")); + overlay.appendChild(el("span", "", label || "Working...")); + root.appendChild(overlay); +} function toast(message) { const t = el("div", "toast", message); document.body.appendChild(t); @@ -374,11 +463,14 @@ function render(data) { if (!data || typeof data !== "object" || !data.view) return; + visibleResultKey = resultKey(data); + hasCommittedView = true; switch (data.view) { case "search_results": lastListView = data; renderSearch(data); break; case "recipe_list": lastListView = data; renderMyRecipes(data); break; case "preview": renderPreview(data); break; case "recipe_card": renderRecipeCard(data); break; + case "cook_mode": renderCookMode(data); break; } } @@ -449,7 +541,7 @@ if (!recipe) { renderEmpty("Couldn't read a recipe from that page."); return; } renderDetail({ title: recipe.title, - imageURL: "", + imageURL: data.image_url || recipe.image_url || recipe.imageUrl || "", cookTime: recipe.cook_time, portions: recipe.portions, ingredients: recipe.ingredients || [], @@ -464,6 +556,7 @@ const recipe = data.recipe; if (!recipe) { renderEmpty("Recipe not found."); return; } renderDetail({ + recipeID: recipe.id, title: recipe.title, imageURL: recipe.imageUrl, cookTime: recipe.cookTimeMinutes, @@ -511,6 +604,11 @@ headings.appendChild(chips); const actions = el("div", "actions"); + if (view.instructions.length) { + const cook = el("button", "savebtn cookbtn", "Start cooking"); + cook.onclick = () => renderCook(view, 0); + actions.appendChild(cook); + } if (view.saved) { actions.appendChild(makeSavedBadge()); } else if (view.saveURL) { @@ -561,6 +659,125 @@ root.appendChild(detail); } +function renderCookMode(data) { + const recipe = data.recipe; + if (!recipe) { renderEmpty("Recipe not found."); return; } + renderCook({ + recipeID: recipe.id, + title: recipe.title, + imageURL: recipe.imageUrl, + cookTime: recipe.cookTimeMinutes, + portions: 0, + ingredients: recipe.ingredients || [], + instructions: recipe.instructions || [], + sourceURL: recipe.sourceUrl, + saved: true, + saveURL: "", + }, Number(data.current_step) || 0); +} + +// Cook mode mounts once. Step changes update text and controls in place so the +// iframe keeps a stable height and the conversation does not jump. +function renderCook(view, initialStep) { + if (!view.instructions.length) { + toast("This recipe does not have cooking instructions yet."); + return; + } + clearRoot(); + visibleResultKey = ""; + brandline("Cook mode", true, () => renderDetail(view)); + const shell = el("section", "cook-shell"); + const head = el("div", "cook-head"); + head.appendChild(el("h2", "", view.title || "Untitled recipe")); + const progress = el("div", "cook-progress"); + const progressFill = el("span"); + progress.appendChild(progressFill); + head.appendChild(progress); + shell.appendChild(head); + + const stage = el("div", "cook-stage"); + const stepPanel = el("div", "cook-step"); + const count = el("div", "cook-count"); + const instruction = el("div", "cook-instruction"); + instruction.setAttribute("aria-live", "polite"); + stepPanel.appendChild(count); + stepPanel.appendChild(instruction); + stage.appendChild(stepPanel); + const ingredientsPanel = el("div", "cook-ingredients"); + ingredientsPanel.hidden = true; + ingredientsPanel.appendChild(el("div", "sect-label", "Ingredients")); + const ingredientList = el("ul", "ing"); + view.ingredients.forEach((ing) => { + const li = el("li"); + li.appendChild(el("span", "box")); + const text = el("span", "txt"); + const quantity = qtyText(ing); + text.textContent = (quantity ? quantity + " " : "") + (ing.original_text || ing.originalText || ing.name || ""); + li.appendChild(text); + li.onclick = () => li.classList.toggle("done"); + ingredientList.appendChild(li); + }); + ingredientsPanel.appendChild(ingredientList); + stage.appendChild(ingredientsPanel); + shell.appendChild(stage); + + const controls = el("div", "cook-controls"); + const previous = el("button", "cook-control", "Previous"); + const ingredients = el("button", "cook-control ingredients-toggle", "Ingredients"); + const next = el("button", "cook-control primary", "Next"); + controls.appendChild(previous); + controls.appendChild(ingredients); + controls.appendChild(next); + shell.appendChild(controls); + root.appendChild(shell); + + let stepIndex = Math.max(0, Math.min(view.instructions.length - 1, initialStep || 0)); + let complete = false; + function showStep() { + complete = false; + ingredientsPanel.hidden = true; + stepPanel.hidden = false; + ingredients.textContent = "Ingredients"; + count.textContent = "Step " + (stepIndex + 1) + " of " + view.instructions.length; + instruction.textContent = view.instructions[stepIndex]; + progressFill.style.width = ((stepIndex + 1) / view.instructions.length * 100) + "%"; + previous.disabled = stepIndex === 0; + next.textContent = stepIndex === view.instructions.length - 1 ? "Finish" : "Next"; + } + previous.onclick = () => { + if (!ingredientsPanel.hidden) { + ingredientsPanel.hidden = true; + stepPanel.hidden = false; + ingredients.textContent = "Ingredients"; + return; + } + if (stepIndex > 0) { stepIndex -= 1; showStep(); } + }; + next.onclick = () => { + if (!ingredientsPanel.hidden) { + ingredientsPanel.hidden = true; + stepPanel.hidden = false; + ingredients.textContent = "Ingredients"; + return; + } + if (complete) { stepIndex = 0; showStep(); return; } + if (stepIndex < view.instructions.length - 1) { stepIndex += 1; showStep(); return; } + complete = true; + count.textContent = "Finished"; + instruction.textContent = "Ready to serve. Nice work."; + progressFill.style.width = "100%"; + previous.disabled = false; + next.textContent = "Cook again"; + }; + ingredients.onclick = () => { + const opening = ingredientsPanel.hidden; + ingredientsPanel.hidden = !opening; + stepPanel.hidden = opening; + ingredients.textContent = opening ? "Back to step" : "Ingredients"; + }; + showStep(); +} + function makeSavedBadge() { const badge = el("span", "savedbadge"); badge.appendChild(el("span", "", "✓")); @@ -605,23 +822,40 @@ async function openPreview(url) { if (!url) return; - skeleton(); - try { render(await callTool("preview_recipe", { url })); } - catch (err) { renderEmpty(err.message); } + const requestID = ++navigationSequence; + if (hasCommittedView) setBusy(true, "Reading recipe..."); else skeleton(); + try { + const data = await callTool("preview_recipe", { url }); + if (requestID === navigationSequence) commitResult(data, "direct"); + } catch (err) { + if (requestID === navigationSequence) renderEmpty(err.message); + } finally { + if (requestID === navigationSequence) setBusy(false); + } } async function openSaved(id) { if (!id) return; - skeleton(); - try { render(await callTool("get_recipe", { recipe_id: String(id) })); } - catch (err) { renderEmpty(err.message); } + const requestID = ++navigationSequence; + if (hasCommittedView) setBusy(true, "Opening recipe..."); else skeleton(); + try { + const data = await callTool("get_recipe", { recipe_id: String(id) }); + if (requestID === navigationSequence) commitResult(data, "direct"); + } catch (err) { + if (requestID === navigationSequence) renderEmpty(err.message); + } finally { + if (requestID === navigationSequence) setBusy(false); + } } async function saveRecipe(button, actions, url) { button.disabled = true; button.textContent = "Saving…"; + inlineToolDepth += 1; + ignoreHostResultsUntil = Date.now() + 12000; try { - await callTool("save_recipe", { url }); + const data = await callTool("save_recipe", { url }); + ignoredHostResultKey = resultKey(data); button.classList.add("saved"); button.textContent = "✓ Saved to SaltyBytes"; toast("Saved — it's waiting in your app."); @@ -629,6 +863,8 @@ button.disabled = false; button.textContent = "Save to SaltyBytes"; toast(err.message); + } finally { + inlineToolDepth = Math.max(0, inlineToolDepth - 1); } } @@ -658,14 +894,17 @@ if (globals.theme === "dark" || globals.theme === "light") { document.documentElement.setAttribute("data-theme", globals.theme); } - if (globals.toolOutput) render(globals.toolOutput); + if (globals.toolOutput) commitResult(globals.toolOutput, "host"); } // MCP Apps postMessage host (Claude and other ext-apps hosts). bridge.on("ui/notifications/tool-result", (params) => { - if (params && params.structuredContent) render(params.structuredContent); + if (params && params.structuredContent) commitResult(params.structuredContent, "host"); +}); +bridge.on("ui/notifications/tool-input", () => { + if (inlineToolDepth > 0) return; + if (hasCommittedView) setBusy(true, "Updating..."); else skeleton(); }); -bridge.on("ui/notifications/tool-input", () => { skeleton(); }); bridge.on("ui/notifications/host-context-changed", (params) => { applyHostContext(params && params.hostContext); }); @@ -675,12 +914,23 @@ applyOpenAIGlobals(event && event.detail && event.detail.globals); }); -const resizeObserver = new ResizeObserver(() => { - bridge.notify("ui/notifications/size-changed", { - width: document.documentElement.scrollWidth, - height: document.documentElement.scrollHeight, +let resizeFrame = 0; +let lastReportedSize = ""; +function scheduleSizeReport() { + if (resizeFrame) cancelAnimationFrame(resizeFrame); + resizeFrame = requestAnimationFrame(() => { + resizeFrame = requestAnimationFrame(() => { + resizeFrame = 0; + const width = Math.ceil(root.scrollWidth); + const height = Math.ceil(root.scrollHeight); + const key = width + "x" + height; + if (key === lastReportedSize) return; + lastReportedSize = key; + bridge.notify("ui/notifications/size-changed", { width, height }); + }); }); -}); +} +const resizeObserver = new ResizeObserver(scheduleSizeReport); (function main() { // ChatGPT: window.openai is present at mount — render immediately from whatever @@ -698,20 +948,13 @@ applyHostContext(init && init.hostContext); bridge.notify("ui/notifications/initialized"); }).catch(() => { /* not an MCP Apps postMessage host */ }); - resizeObserver.observe(document.body); + resizeObserver.observe(root); + scheduleSizeReport(); })(); -// ChatGPT can set window.openai.toolOutput a tick after mount without firing -// "openai:set_globals"; poll briefly so a late delivery still renders. (Kept as -// groundwork; ChatGPT's sandbox does not currently execute this widget's script.) -(function pollOpenAI() { - let n = 0; - const timer = setInterval(() => { - const oa = openAIHost(); - if (oa && oa.toolOutput) { clearInterval(timer); applyOpenAIGlobals(oa); } - else if (++n >= 24) { clearInterval(timer); } - }, 250); -})(); +// One deduplicated late read covers runtimes that attach toolOutput just after +// mount without reintroducing the old polling/render loop. +setTimeout(() => applyOpenAIGlobals(openAIHost()), 250); diff --git a/internal/models/canonical_recipe.go b/internal/models/canonical_recipe.go index 7312488..55398a7 100644 --- a/internal/models/canonical_recipe.go +++ b/internal/models/canonical_recipe.go @@ -21,6 +21,7 @@ type CanonicalRecipe struct { gorm.Model NormalizedURL string `gorm:"uniqueIndex;size:2048;not null"` OriginalURL string `gorm:"size:2048;not null"` + ImageURL string `gorm:"size:2048" json:"image_url,omitempty"` RecipeData RecipeDef `gorm:"type:jsonb;not null"` ExtractionMethod ExtractionMethod `gorm:"type:text;not null"` HitCount int `gorm:"default:0"` diff --git a/internal/repository/canonical_recipe.go b/internal/repository/canonical_recipe.go index 2519675..d9219bb 100644 --- a/internal/repository/canonical_recipe.go +++ b/internal/repository/canonical_recipe.go @@ -42,7 +42,7 @@ func (r *CanonicalRecipeRepository) GetByNormalizedURL(normalizedURL string) (*m func (r *CanonicalRecipeRepository) Upsert(entry *models.CanonicalRecipe) error { return r.DB.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "normalized_url"}}, - DoUpdates: clause.AssignmentColumns([]string{"recipe_data", "extraction_method", "fetched_at", "last_accessed_at", "original_url", "embedding", "prompt_version", "is_multi_page"}), + DoUpdates: clause.AssignmentColumns([]string{"recipe_data", "extraction_method", "fetched_at", "last_accessed_at", "original_url", "image_url", "embedding", "prompt_version", "is_multi_page"}), }).Create(entry).Error } diff --git a/internal/service/import.go b/internal/service/import.go index 93d442a..a7666cc 100644 --- a/internal/service/import.go +++ b/internal/service/import.go @@ -175,7 +175,8 @@ func (s *ImportService) ImportFromURL(ctx context.Context, rawURL string, user * log.Info("import from canonical cache hit") go s.CanonicalRepo.IncrementHitCount(canonical.ID) canonicalID := canonical.ID - recipeResp, _, createErr := s.createImportedRecipe(ctx, &canonical.RecipeData, user, models.RecipeTypeImportLink, rawURL, "", &canonicalID, nil, canonical.PromptVersion) + imageURL := s.repairCanonicalImage(ctx, canonical) + recipeResp, _, createErr := s.createImportedRecipe(ctx, &canonical.RecipeData, user, models.RecipeTypeImportLink, rawURL, imageURL, &canonicalID, nil, canonical.PromptVersion) return recipeResp, createErr } } @@ -195,6 +196,7 @@ func (s *ImportService) ImportFromURL(ctx context.Context, rawURL string, user * entry := &models.CanonicalRecipe{ NormalizedURL: normalizedURL, OriginalURL: rawURL, + ImageURL: imageURL, RecipeData: *recipeDef, ExtractionMethod: method, FetchedAt: now, @@ -228,7 +230,8 @@ func (s *ImportService) ImportFromCanonical(ctx context.Context, canonicalID uin go s.CanonicalRepo.IncrementHitCount(canonical.ID) cID := canonical.ID - resp, _, createErr := s.createImportedRecipe(ctx, &canonical.RecipeData, user, models.RecipeTypeImportLink, canonical.OriginalURL, "", &cID, nil, canonical.PromptVersion) + imageURL := s.repairCanonicalImage(ctx, canonical) + resp, _, createErr := s.createImportedRecipe(ctx, &canonical.RecipeData, user, models.RecipeTypeImportLink, canonical.OriginalURL, imageURL, &cID, nil, canonical.PromptVersion) return resp, createErr } @@ -468,6 +471,7 @@ func (s *ImportService) extractFromURLInner(ctx context.Context, rawURL string) // Phase 2: Extract recipe from HTML recipeDef, hashtags, imageURL, jsonLDErr := extractJSONLD(html) if jsonLDErr == nil && recipeDef != nil { + imageURL = recipeImageURL(html, imageURL) recipeDef.SourceURL = rawURL method := models.ExtractionJSONLD if usedFirecrawl { @@ -510,7 +514,7 @@ func (s *ImportService) extractFromURLInner(ctx context.Context, rawURL string) if s.Policy != nil { s.Policy.RecordOutcome(rawURL, method, true) } - return &def, result.Hashtags, "", method, result.PromptVersion, nil + return &def, result.Hashtags, recipeImageURL(html, ""), method, result.PromptVersion, nil } // fetchAndExtractWithHTML fetches a URL once and returns both the extracted @@ -833,6 +837,7 @@ func (s *ImportService) ImportManual(ctx context.Context, recipeDef *models.Reci // recipe or a multi-recipe page that needs resolution. type PreviewResult struct { Recipe *models.RecipeDef `json:"recipe,omitempty"` + ImageURL string `json:"image_url,omitempty"` CanonicalID *uint `json:"canonical_id,omitempty"` IsMulti bool `json:"is_multi"` MultiID string `json:"multi_id,omitempty"` @@ -843,6 +848,56 @@ type PreviewResult struct { FromCache bool `json:"from_cache,omitempty"` } +// recipeImageURL prefers the recipe's structured-data image and falls back to +// the page's social-preview hero for sites whose JSON-LD omits an image. +func recipeImageURL(html, structuredImageURL string) string { + if imageURL := strings.TrimSpace(structuredImageURL); imageURL != "" { + return imageURL + } + return pageImageURL(html) +} + +// repairCanonicalImage best-effort repairs legacy canonical rows created +// before image_url was persisted. Repair is bounded so a cache hit remains +// responsive, and failure never prevents previewing or saving the recipe. +func (s *ImportService) repairCanonicalImage(ctx context.Context, canonical *models.CanonicalRecipe) string { + if canonical == nil || strings.TrimSpace(canonical.ImageURL) != "" { + if canonical == nil { + return "" + } + return strings.TrimSpace(canonical.ImageURL) + } + + sourceURL := canonical.OriginalURL + if sourceURL == "" { + sourceURL = canonical.RecipeData.SourceURL + } + if sourceURL == "" || ValidateExternalURL(sourceURL) != nil { + return "" + } + + repairCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + html, err := s.fetchHTML(repairCtx, sourceURL) + if err != nil { + return "" + } + _, _, structuredImageURL, _ := s.extractRecipeFromHTML(html, sourceURL) + imageURL := recipeImageURL(html, structuredImageURL) + if imageURL == "" { + return "" + } + + canonical.ImageURL = imageURL + if s.CanonicalRepo != nil { + if err := s.CanonicalRepo.Upsert(canonical); err != nil { + logger.Get().Warn("failed to repair canonical image", + zap.Uint("canonical_id", canonical.ID), zap.Error(err)) + } + } + return imageURL +} + // CanonicalSource resolves a canonical cache id to its title and source URL. // This is the glue for universal links: the public site's recipe pages live at // saltybytes.ai/r/, so a link opened in the app carries only the @@ -884,6 +939,7 @@ func (s *ImportService) PreviewFromURL(ctx context.Context, rawURL string) (*mod if canonical, err := s.CanonicalRepo.GetByNormalizedURL(normalizedURL); err == nil && !canonical.IsMultiPage { log.Info("preview canonical cache hit") go s.CanonicalRepo.IncrementHitCount(canonical.ID) + s.repairCanonicalImage(ctx, canonical) data := canonical.RecipeData if data.SourceURL == "" { data.SourceURL = rawURL @@ -894,7 +950,7 @@ func (s *ImportService) PreviewFromURL(ctx context.Context, rawURL string) (*mod } } - recipeDef, _, _, method, promptVersion, err := s.extractFromURL(ctx, rawURL) + recipeDef, _, imageURL, method, promptVersion, err := s.extractFromURL(ctx, rawURL) if err != nil { log.Error("preview extraction failed", zap.Error(err)) return nil, nil, err @@ -908,6 +964,7 @@ func (s *ImportService) PreviewFromURL(ctx context.Context, rawURL string) (*mod entry := &models.CanonicalRecipe{ NormalizedURL: normalizedURL, OriginalURL: rawURL, + ImageURL: imageURL, RecipeData: *recipeDef, ExtractionMethod: method, FetchedAt: now, @@ -986,7 +1043,8 @@ func (s *ImportService) WarmURL(ctx context.Context, resolver *MultiRecipeResolv } // A single JSON-LD recipe is the common, free case — cache it without AI. - recipeDef, _, _, method := s.extractRecipeFromHTML(html, rawURL) + recipeDef, _, imageURL, method := s.extractRecipeFromHTML(html, rawURL) + imageURL = recipeImageURL(html, imageURL) if recipeDef == nil { // No structured data. Only here do we spend AI: first confirm it isn't a // link-style collection (so a listicle isn't mis-cached as one recipe), @@ -1037,6 +1095,7 @@ func (s *ImportService) WarmURL(ctx context.Context, resolver *MultiRecipeResolv return s.CanonicalRepo.Upsert(&models.CanonicalRecipe{ NormalizedURL: normalizedURL, OriginalURL: rawURL, + ImageURL: imageURL, RecipeData: *recipeDef, ExtractionMethod: method, FetchedAt: now, @@ -1079,12 +1138,13 @@ func (s *ImportService) PreviewFromURLWithMultiCheck(ctx context.Context, rawURL if canonical, err := s.CanonicalRepo.GetByNormalizedURL(normalizedURL); err == nil && !canonical.IsMultiPage { log.Info("preview canonical cache hit") go s.CanonicalRepo.IncrementHitCount(canonical.ID) + imageURL := s.repairCanonicalImage(ctx, canonical) data := canonical.RecipeData if data.SourceURL == "" { data.SourceURL = rawURL } canonicalID := canonical.ID - return &PreviewResult{Recipe: &data, CanonicalID: &canonicalID, FromCache: true}, nil + return &PreviewResult{Recipe: &data, ImageURL: imageURL, CanonicalID: &canonicalID, FromCache: true}, nil } } } @@ -1142,7 +1202,8 @@ func (s *ImportService) PreviewFromURLWithMultiCheck(ctx context.Context, rawURL } // Single recipe — extract from the HTML we already fetched - recipeDef, _, _, method := s.extractRecipeFromHTML(html, rawURL) + recipeDef, _, imageURL, method := s.extractRecipeFromHTML(html, rawURL) + imageURL = recipeImageURL(html, imageURL) if recipeDef == nil { // JSON-LD failed — try AI extraction from the same HTML provider := s.PreviewProvider @@ -1193,6 +1254,7 @@ func (s *ImportService) PreviewFromURLWithMultiCheck(ctx context.Context, rawURL entry := &models.CanonicalRecipe{ NormalizedURL: normalizedURL, OriginalURL: rawURL, + ImageURL: imageURL, RecipeData: *recipeDef, ExtractionMethod: method, FetchedAt: now, @@ -1207,7 +1269,7 @@ func (s *ImportService) PreviewFromURLWithMultiCheck(ctx context.Context, rawURL } } - return &PreviewResult{Recipe: recipeDef, CanonicalID: canonicalID}, nil + return &PreviewResult{Recipe: recipeDef, ImageURL: imageURL, CanonicalID: canonicalID}, nil } // createImportedRecipe creates a recipe in the DB from a RecipeDef. diff --git a/internal/service/import_service_test.go b/internal/service/import_service_test.go index 19c6ab8..6381209 100644 --- a/internal/service/import_service_test.go +++ b/internal/service/import_service_test.go @@ -337,9 +337,10 @@ func TestPreviewFromURL_OldCanonicalStillServed(t *testing.T) { } } -func TestPreviewFromURLWithMultiCheck_ServesCachedSingle(t *testing.T) { +func TestPreviewFromURLWithMultiCheck_ServesImageCompleteCachedSingle(t *testing.T) { repo := testutil.NewMockRecipeRepo() canonical := testutil.TestCanonicalRecipe() // IsMultiPage defaults to false + canonical.ImageURL = "https://example.com/pancakes.jpg" fetched := false svc := newTestImportService(repo, nil, nil) svc.CanonicalRepo = &testutil.MockCanonicalRecipeRepo{ @@ -372,6 +373,44 @@ func TestPreviewFromURLWithMultiCheck_ServesCachedSingle(t *testing.T) { } } +func TestPreviewFromURLWithMultiCheck_RepairsLegacyCachedImage(t *testing.T) { + repo := testutil.NewMockRecipeRepo() + canonical := testutil.TestCanonicalRecipe() + canonical.ImageURL = "" + fetched := false + upsertedImageURL := "" + svc := newTestImportService(repo, nil, nil) + svc.CanonicalRepo = &testutil.MockCanonicalRecipeRepo{ + GetByNormalizedURLFunc: func(string) (*models.CanonicalRecipe, error) { return canonical, nil }, + UpsertFunc: func(entry *models.CanonicalRecipe) error { + upsertedImageURL = entry.ImageURL + return nil + }, + } + svc.HTTPFetchOverride = func(ctx context.Context, url string) ([]byte, int, error) { + fetched = true + return []byte(``), 200, nil + } + + resolver := NewMultiRecipeResolver(NewMultiRecipeRegistry(), svc) + result, err := svc.PreviewFromURLWithMultiCheck(context.Background(), "https://example.com/classic-pancakes", resolver) + if err != nil { + t.Fatalf("PreviewFromURLWithMultiCheck error: %v", err) + } + if !result.FromCache { + t.Error("expected the cached recipe to remain the preview source") + } + if !fetched { + t.Error("expected a metadata fetch to repair the missing cached image") + } + if result.ImageURL != "https://example.com/repaired-pancakes.jpg" { + t.Errorf("image_url = %q, want repaired image", result.ImageURL) + } + if upsertedImageURL != result.ImageURL { + t.Errorf("upserted image_url = %q, want %q", upsertedImageURL, result.ImageURL) + } +} + func TestPreviewFromURLWithMultiCheck_SkipsMultiPageMarker(t *testing.T) { repo := testutil.NewMockRecipeRepo() marker := &models.CanonicalRecipe{NormalizedURL: "x", IsMultiPage: true, RecipeData: models.RecipeDef{}}