feat: cooking assistant can propose creating a recipe
Gives the general chat a createRecipe tool (Vercel AI SDK, stepCountIs(3)) scoped so it can only ever produce a draft — the tool's execute is a pure echo, no DB write. The route surfaces the tool call as proposedRecipe alongside the normal text answer; the chat UI renders it as a card with explicit Create/Discard buttons. Create POSTs to the existing /api/v1/recipes endpoint — the same code path and tier/recipe-count limit the manual editor already goes through — so there's exactly one place that actually creates a recipe row, and nothing happens without the user clicking Create. Scoped to the general assistant only (not per-recipe chat), and to one tool for now — addToShoppingList/generateMealPlan are follow-ups. v0.45.0
This commit is contained in:
@@ -2,6 +2,13 @@
|
||||
|
||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||
|
||||
## 0.45.0 — 2026-07-17 17:30
|
||||
|
||||
### Added
|
||||
- The cooking assistant can now propose creating a recipe when you explicitly ask it to ("make me a recipe for X", "write that down") — it drafts one right in the chat with a Create/Discard choice. Nothing is saved until you confirm; confirming goes through the same recipe-creation endpoint (and the same per-tier recipe limit) as the manual editor.
|
||||
|
||||
Note: this is the first step of chatbot-driven actions — only recipe creation for now. Adding to a shopping list or generating a meal plan from chat are tracked as follow-ups, not yet built.
|
||||
|
||||
## 0.44.1 — 2026-07-17 17:00
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { generateText } from "ai";
|
||||
import { generateText, stepCountIs } from "ai";
|
||||
import { db, chatMessages, aiConversations, sql } from "@epicure/db";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
@@ -8,6 +8,7 @@ import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
|
||||
import { resolveModel } from "@/lib/ai/factory";
|
||||
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
|
||||
import { createRecipeTool } from "@/lib/ai/tools/create-recipe-tool";
|
||||
|
||||
const Schema = z.object({
|
||||
question: z.string().min(1).max(500),
|
||||
@@ -43,13 +44,16 @@ export async function POST(req: NextRequest) {
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
generateText({
|
||||
model,
|
||||
system: `You are Epicure, a helpful culinary assistant answering general cooking questions — not tied to any specific recipe (techniques, substitutions, timing, equipment, food safety, etc). If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question has nothing to do with cooking or food, politely redirect. Keep answers under 200 words. Respond in ${lang}.${bioContext}`,
|
||||
system: `You are Epicure, a helpful culinary assistant answering general cooking questions — not tied to any specific recipe (techniques, substitutions, timing, equipment, food safety, etc). If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question has nothing to do with cooking or food, politely redirect. Keep answers under 200 words. Respond in ${lang}.\n\nYou can propose creating a recipe with the createRecipe tool when the user explicitly asks you to create, save, or write down a recipe — e.g. "make me a recipe for X" or "write that down as a recipe". Don't call it just because a recipe came up in conversation. When you do call it, still write a short text reply too (e.g. "Here's a draft — check it below and confirm if it looks right"), since calling the tool only drafts the recipe for the user to review; it never saves anything by itself.${bioContext}`,
|
||||
prompt: parsed.data.question,
|
||||
tools: { createRecipe: createRecipeTool },
|
||||
stopWhen: stepCountIs(3),
|
||||
}), { skipQuota: aiConfig.isByok }
|
||||
);
|
||||
if (!result.ok) return result.response;
|
||||
|
||||
const { conversationId } = parsed.data;
|
||||
const proposedRecipe = result.data.toolCalls.find((c) => c.toolName === "createRecipe")?.input;
|
||||
|
||||
void db.insert(chatMessages).values([
|
||||
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: null, conversationId, role: "user", content: parsed.data.question },
|
||||
@@ -68,5 +72,5 @@ export async function POST(req: NextRequest) {
|
||||
`).catch((err) => console.error("[cooking-chat] failed to touch conversation", err));
|
||||
}
|
||||
|
||||
return NextResponse.json({ answer: result.data.text });
|
||||
return NextResponse.json({ answer: result.data.text, proposedRecipe });
|
||||
}
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { MessageCircle, Send, Bot, User, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { MessageCircle, Send, Bot, User, Maximize2, Minimize2, ChefHat, Check, X, Loader2 } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatHistorySearch } from "./chat-history-search";
|
||||
import { ConversationMenu, type Conversation } from "./conversation-menu";
|
||||
|
||||
type RecipeProposal = {
|
||||
title: string;
|
||||
description?: string;
|
||||
baseServings: number;
|
||||
recipeType: "dish" | "drink";
|
||||
difficulty?: "easy" | "medium" | "hard";
|
||||
prepMins?: number;
|
||||
cookMins?: number;
|
||||
ingredients: Array<{ rawName: string; quantity?: number; unit?: string }>;
|
||||
steps: Array<{ instruction: string }>;
|
||||
};
|
||||
|
||||
type Message = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
proposedRecipe?: RecipeProposal;
|
||||
proposalStatus?: "pending" | "creating" | "created" | "discarded";
|
||||
};
|
||||
|
||||
type HistoryEntry = { role: "user" | "assistant"; content: string };
|
||||
@@ -21,6 +37,7 @@ type HistoryEntry = { role: "user" | "assistant"; content: string };
|
||||
export function CookingAssistantPanel() {
|
||||
const t = useTranslations("cookingChat");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
@@ -102,10 +119,15 @@ export function CookingAssistantPanel() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ question, conversationId: conversationId ?? undefined }),
|
||||
});
|
||||
const data = await res.json() as { answer?: string; error?: string };
|
||||
const data = await res.json() as { answer?: string; error?: string; proposedRecipe?: RecipeProposal };
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: data.answer ?? t("sorry") },
|
||||
{
|
||||
role: "assistant",
|
||||
content: data.answer ?? t("sorry"),
|
||||
proposedRecipe: data.proposedRecipe,
|
||||
proposalStatus: data.proposedRecipe ? "pending" : undefined,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
setMessages((prev) => [
|
||||
@@ -117,6 +139,49 @@ export function CookingAssistantPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
async function handleCreateProposal(index: number) {
|
||||
const proposal = messages[index]?.proposedRecipe;
|
||||
if (!proposal) return;
|
||||
|
||||
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, proposalStatus: "creating" } : m)));
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/recipes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: proposal.title,
|
||||
description: proposal.description,
|
||||
baseServings: proposal.baseServings,
|
||||
recipeType: proposal.recipeType,
|
||||
difficulty: proposal.difficulty,
|
||||
prepMins: proposal.prepMins,
|
||||
cookMins: proposal.cookMins,
|
||||
visibility: "private",
|
||||
aiGenerated: true,
|
||||
ingredients: proposal.ingredients.map((ing, i) => ({ rawName: ing.rawName, quantity: ing.quantity, unit: ing.unit, order: i })),
|
||||
steps: proposal.steps.map((step, i) => ({ instruction: step.instruction, order: i })),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, proposalStatus: "pending" } : m)));
|
||||
toast.error(t("proposalCreateFailed"));
|
||||
return;
|
||||
}
|
||||
const { id } = await res.json() as { id: string };
|
||||
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, proposalStatus: "created" } : m)));
|
||||
toast.success(t("proposalCreated"));
|
||||
router.push(`/recipes/${id}/edit`);
|
||||
} catch {
|
||||
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, proposalStatus: "pending" } : m)));
|
||||
toast.error(t("proposalCreateFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscardProposal(index: number) {
|
||||
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, proposalStatus: "discarded" } : m)));
|
||||
}
|
||||
|
||||
const suggestions = [
|
||||
t("suggestion1"),
|
||||
t("suggestion2"),
|
||||
@@ -195,33 +260,77 @@ export function CookingAssistantPanel() {
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex gap-2 items-start",
|
||||
msg.role === "user" && "flex-row-reverse"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"h-7 w-7 shrink-0 rounded-full flex items-center justify-center",
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{msg.role === "user" ? <User className="h-3.5 w-3.5" /> : <Bot className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"rounded-xl px-3 py-2 text-sm max-w-[80%] leading-relaxed",
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground whitespace-pre-wrap"
|
||||
: "bg-muted space-y-2 [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-4 [&_ol]:pl-4 [&_strong]:font-semibold [&_li]:my-0.5"
|
||||
)}>
|
||||
{msg.role === "assistant" ? (
|
||||
<ReactMarkdown>{msg.content}</ReactMarkdown>
|
||||
) : (
|
||||
msg.content
|
||||
<div key={i} className="space-y-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 items-start",
|
||||
msg.role === "user" && "flex-row-reverse"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"h-7 w-7 shrink-0 rounded-full flex items-center justify-center",
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{msg.role === "user" ? <User className="h-3.5 w-3.5" /> : <Bot className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"rounded-xl px-3 py-2 text-sm max-w-[80%] leading-relaxed",
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground whitespace-pre-wrap"
|
||||
: "bg-muted space-y-2 [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-4 [&_ol]:pl-4 [&_strong]:font-semibold [&_li]:my-0.5"
|
||||
)}>
|
||||
{msg.role === "assistant" ? (
|
||||
<ReactMarkdown>{msg.content}</ReactMarkdown>
|
||||
) : (
|
||||
msg.content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg.proposedRecipe && (
|
||||
<div className="ml-9 rounded-lg border bg-card p-3 space-y-2 max-w-[80%]">
|
||||
<div className="flex items-center gap-2">
|
||||
<ChefHat className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
<span className="font-medium text-sm truncate">{msg.proposedRecipe.title}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proposalSummary", {
|
||||
ingredients: msg.proposedRecipe.ingredients.length,
|
||||
steps: msg.proposedRecipe.steps.length,
|
||||
})}
|
||||
</p>
|
||||
{msg.proposalStatus === "created" ? (
|
||||
<p className="text-xs text-primary flex items-center gap-1"><Check className="h-3 w-3" />{t("proposalCreated")}</p>
|
||||
) : msg.proposalStatus === "discarded" ? (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1"><X className="h-3 w-3" />{t("proposalDiscarded")}</p>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
disabled={msg.proposalStatus === "creating"}
|
||||
onClick={() => void handleCreateProposal(i)}
|
||||
>
|
||||
{msg.proposalStatus === "creating"
|
||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||
: <Check className="h-3 w-3" />}
|
||||
{t("proposalCreateButton")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
disabled={msg.proposalStatus === "creating"}
|
||||
onClick={() => handleDiscardProposal(i)}
|
||||
>
|
||||
{t("proposalDiscardButton")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
const CreateRecipeInputSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
baseServings: z.number().int().min(1).max(100).default(4),
|
||||
recipeType: z.enum(["dish", "drink"]).default("dish"),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||
prepMins: z.number().int().min(0).max(1440).optional(),
|
||||
cookMins: z.number().int().min(0).max(1440).optional(),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string().min(1).max(200).describe("Ingredient name only, e.g. 'flour' — never combined with quantity/unit"),
|
||||
quantity: z.number().optional().describe("A number only, e.g. 0.25, 1.5, 2"),
|
||||
unit: z.string().max(50).optional().describe("e.g. 'cup', 'tbsp', 'g'"),
|
||||
})).min(1).max(100),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string().min(1).max(2000),
|
||||
})).min(1).max(100),
|
||||
});
|
||||
|
||||
export type CreateRecipeToolInput = z.infer<typeof CreateRecipeInputSchema>;
|
||||
|
||||
/**
|
||||
* Lets the cooking assistant propose a new recipe from the conversation.
|
||||
* Deliberately does NOT write to the database — `execute` just validates and
|
||||
* echoes the input back as a draft. The chat UI renders that draft with an
|
||||
* explicit Confirm button, which POSTs to the existing /api/v1/recipes
|
||||
* endpoint (the same one the manual recipe editor uses) — so nothing gets
|
||||
* created without the user directly approving it, and there's exactly one
|
||||
* code path that actually writes a recipe row.
|
||||
*/
|
||||
export const createRecipeTool = tool({
|
||||
description:
|
||||
"Propose creating a new recipe based on the conversation. This only drafts the recipe for the user to review — it does not save anything. Only call this when the user has actually asked you to create/save/write down a recipe, not just discussed one.",
|
||||
inputSchema: CreateRecipeInputSchema,
|
||||
execute: async (input: CreateRecipeToolInput) => input,
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.44.1";
|
||||
export const APP_VERSION = "0.45.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.45.0",
|
||||
date: "2026-07-17 17:30",
|
||||
added: [
|
||||
"The cooking assistant can now propose creating a recipe when you explicitly ask it to (\"make me a recipe for X\", \"write that down\") — it drafts one right in the chat with a Create/Discard choice. Nothing is saved until you confirm; confirming goes through the same recipe-creation endpoint (and the same per-tier recipe limit) as the manual editor.",
|
||||
],
|
||||
notes: "First step of chatbot-driven actions — only recipe creation for now. Adding recipes to a shopping list or generating a meal plan from chat are tracked as follow-ups, not yet built.",
|
||||
},
|
||||
{
|
||||
version: "0.44.1",
|
||||
date: "2026-07-17 17:00",
|
||||
|
||||
@@ -313,7 +313,14 @@ export function generateOpenApiSpec(): object {
|
||||
registry.registerPath({ method: "post", path: "/api/v1/ai/batch-cook/generate", summary: "Generate a batch-cooking plan (multiple dishes) as a new recipe", description: "Rate-limited: 3 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ dinners: z.number().int().min(0).max(6).default(4), lunches: z.number().int().min(0).max(6).default(0), servings: z.number().int().min(1).max(12).default(4), dietaryPrefs: z.string().max(200).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(), description: z.string().max(500).optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or no dinners/lunches chosen", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/ai/meal-plan/generate", summary: "Generate a week's meal plan (creates a draft recipe per entry)", description: "Rate-limited: 3 req/min. Consumes AI quota. Charges your tier's recipe limit for each generated entry (refunded if the limit is exceeded).", security, request: { body: { content: { "application/json": { schema: z.object({ weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), dietaryPrefs: z.string().max(200).optional(), servings: z.number().int().min(1).max(20).default(2), days: z.array(z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])).min(1).max(7).default(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]), usePantry: z.boolean().default(false), pantryMode: z.boolean().default(false), difficulty: z.enum(["easy", "medium", "hard"]).optional(), targetNutritionGoals: z.boolean().default(false) }) } }, required: true } }, responses: { 200: { description: "Created entries", content: { "application/json": { schema: z.object({ weekStart: z.string(), entries: z.array(z.object({ id: z.string(), day: z.string(), mealType: z.string(), recipeId: z.string(), recipeTitle: z.string() })) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/ai/recipe-ideas", summary: "Generate 6 diverse recipe idea cards (not saved)", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().max(300).optional() }) } } } }, responses: { 200: { description: "Six recipe ideas", content: { "application/json": { schema: z.array(z.object({ title: z.string(), description: z.string(), tags: z.array(z.string()).max(4), difficulty: z.enum(["easy", "medium", "hard"]), totalMins: z.number().int().optional() })) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/ai/cooking-chat", summary: "Ask the general (not recipe-specific) cooking assistant a question", description: "Rate-limited: 30 req/min. Consumes AI quota. Returns a single JSON response (not streamed); persists the exchange to chat history. Pass conversationId (from POST /api/v1/ai/conversations) to group messages into a named conversation and auto-title it from the first question.", security, request: { body: { content: { "application/json": { schema: z.object({ question: z.string().min(1).max(500), conversationId: z.string().uuid().optional() }) } }, required: true } }, responses: { 200: { description: "Answer", content: { "application/json": { schema: z.object({ answer: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
const ProposedRecipeRef = registry.register("ProposedRecipe", z.object({
|
||||
title: z.string(), description: z.string().optional(), baseServings: z.number().int(),
|
||||
recipeType: z.enum(["dish", "drink"]), difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||
prepMins: z.number().int().optional(), cookMins: z.number().int().optional(),
|
||||
ingredients: z.array(z.object({ rawName: z.string(), quantity: z.number().optional(), unit: z.string().optional() })),
|
||||
steps: z.array(z.object({ instruction: z.string() })),
|
||||
}));
|
||||
registry.registerPath({ method: "post", path: "/api/v1/ai/cooking-chat", summary: "Ask the general (not recipe-specific) cooking assistant a question", description: "Rate-limited: 30 req/min. Consumes AI quota. Returns a single JSON response (not streamed); persists the exchange to chat history. Pass conversationId (from POST /api/v1/ai/conversations) to group messages into a named conversation and auto-title it from the first question. If the user asks the assistant to create/save a recipe, the response may include proposedRecipe — a draft the assistant proposed but did NOT save; the caller must POST it to /api/v1/recipes itself to actually create it.", security, request: { body: { content: { "application/json": { schema: z.object({ question: z.string().min(1).max(500), conversationId: z.string().uuid().optional() }) } }, required: true } }, responses: { 200: { description: "Answer", content: { "application/json": { schema: z.object({ answer: z.string(), proposedRecipe: ProposedRecipeRef.optional() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
const AiConversationRef = registry.register("AiConversation", z.object({
|
||||
id: z.string(), title: z.string().nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
||||
}));
|
||||
|
||||
@@ -459,7 +459,13 @@
|
||||
"subtitle": "Ask any cooking question — not tied to a specific recipe",
|
||||
"intro": "Ask anything about cooking — techniques, substitutions, timing, equipment…",
|
||||
"inputPlaceholder": "Ask a question…",
|
||||
"send": "Send message"
|
||||
"send": "Send message",
|
||||
"proposalSummary": "{ingredients} ingredients · {steps} steps",
|
||||
"proposalCreateButton": "Create recipe",
|
||||
"proposalDiscardButton": "Discard",
|
||||
"proposalCreated": "Recipe created — opening editor…",
|
||||
"proposalDiscarded": "Discarded",
|
||||
"proposalCreateFailed": "Failed to create recipe"
|
||||
},
|
||||
"conversations": {
|
||||
"menuAriaLabel": "Conversations",
|
||||
|
||||
@@ -459,7 +459,13 @@
|
||||
"subtitle": "Posez n'importe quelle question de cuisine — pas liée à une recette précise",
|
||||
"intro": "Demandez n'importe quoi sur la cuisine — techniques, substitutions, minutage, équipement…",
|
||||
"inputPlaceholder": "Posez une question…",
|
||||
"send": "Envoyer le message"
|
||||
"send": "Envoyer le message",
|
||||
"proposalSummary": "{ingredients} ingrédients · {steps} étapes",
|
||||
"proposalCreateButton": "Créer la recette",
|
||||
"proposalDiscardButton": "Ignorer",
|
||||
"proposalCreated": "Recette créée — ouverture de l'éditeur…",
|
||||
"proposalDiscarded": "Ignorée",
|
||||
"proposalCreateFailed": "Échec de la création de la recette"
|
||||
},
|
||||
"conversations": {
|
||||
"menuAriaLabel": "Conversations",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.44.1",
|
||||
"version": "0.45.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.44.1",
|
||||
"version": "0.45.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Reference in New Issue
Block a user