Stronger prompting, UI update

This commit is contained in:
2026-03-12 20:17:25 +00:00
parent 3127ccbdbe
commit 48b015e095
10 changed files with 236 additions and 2 deletions

View File

@@ -97,4 +97,14 @@ public class RecipeController : ControllerBase
return Ok(myRecipes);
}
[HttpPost("consult")]
public async Task<IActionResult> Consult([FromBody] ChatRequestDto request)
{
if (string.IsNullOrWhiteSpace(request.Prompt))
return BadRequest("The Chef needs a prompt.");
var result = await _recipeService.ConsultChefAsync(request.Prompt);
return Ok(result);
}
}

View File

@@ -0,0 +1,6 @@
namespace Seasoned.Backend.DTOs;
public class ChatRequestDto
{
public string Prompt { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,7 @@
namespace Seasoned.Backend.DTOs;
public class ChefConsultResponseDto
{
public string Reply { get; set; } = string.Empty;
public RecipeResponseDto? Recipe { get; set; }
}

View File

@@ -6,4 +6,5 @@ public class RecipeResponseDto
public string Icon { get; set; } = "mdi-silverware-fork-knife";
public List<string> Ingredients { get; set; } = new();
public List<string> Instructions { get; set; } = new();
}

View File

@@ -5,4 +5,5 @@ namespace Seasoned.Backend.Services;
public interface IRecipeService
{
Task<RecipeResponseDto> ParseRecipeImageAsync(IFormFile image);
Task<ChefConsultResponseDto> ConsultChefAsync(string userPrompt);
}

View File

@@ -84,4 +84,59 @@ public class RecipeService : IRecipeService
return new RecipeResponseDto { Title = "Parsing Error" };
}
}
public async Task<ChefConsultResponseDto> ConsultChefAsync(string userPrompt)
{
var googleAI = new GoogleAI(_apiKey);
var model = googleAI.GenerativeModel("gemini-3.1-flash-lite-preview");
var systemPrompt = @"You are the 'Seasoned' Head Chef, a master of real-world culinary arts.
You operate a professional kitchen and only provide advice that can be used in a real kitchen.
STRICT CONTENT RULES:
1. REAL FOOD ONLY: You specialize in real-world ingredients and techniques.
2. CELEBRITY CHEFS: You can provide recipes from real chefs like Gordon Ramsay or Julia Child.
3. FICTIONAL FOOD TRANSLATION: If a user asks for food from a game (like a Skyrim Sweetroll) or movie,
do NOT give game mechanics. Instead, provide a REAL-WORLD recipe that recreates that item.
In your 'reply', treat it like a fun culinary challenge.
4. REFUSAL POLICY: If the user asks about non-food topics (video game strategies, tech support, politics, or 'Minecraft crafting grids'),
politely refuse. Stay in character: 'The Chef's Grimoire is for spices, not spells' or 'I deal in pans, not pixels.'
RESPONSE FORMAT:
You MUST return ONLY a raw JSON object with these keys:
{
""reply"": ""A friendly, thematic response from the Chef."",
""recipe"": {
""title"": ""string"",
""icon"": ""string (must be a valid mdi- icon name)"",
""ingredients"": [""string"", ""string""],
""instructions"": [""string"", ""string""]
}
}
Note: Set the 'recipe' object to null if you are only chatting or refusing a non-food prompt.";
var fullPrompt = $"{systemPrompt}\n\nUser Question: {userPrompt}";
var generationConfig = new GenerationConfig {
ResponseMimeType = "application/json",
Temperature = 0.7f
};
var request = new GenerateContentRequest(fullPrompt, generationConfig);
var response = await model.GenerateContent(request);
try
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
string jsonToParse = response.Text ?? "{ \"reply\": \"The chef is speechless. Try again?\" }";
var result = JsonSerializer.Deserialize<ChefConsultResponseDto>(jsonToParse, options);
return result ?? new ChefConsultResponseDto { Reply = "Chef is a bit confused!" };
}
catch (JsonException)
{
return new ChefConsultResponseDto { Reply = "The kitchen is a mess right now. Try again?" };
}
}
}

View File

@@ -265,3 +265,63 @@
font-size: 1.05rem;
letter-spacing: 0.02em;
}
.chat-container {
background: rgba(244, 237, 225, 0.6);
border: 1px dashed #d1c7b7;
border-radius: 8px;
}
.chat-container {
width: 100%;
background-color: rgba(62, 42, 20, 0.03) !important;
border: 2px dashed #8c857b;
border-radius: 12px;
padding: 20px;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
}
.chat-container:focus-within {
background-color: rgba(85, 107, 47, 0.05) !important;
border-color: #556b2f;
}
.chat-input .v-field__input {
color: #5d4037 !important;
font-family: 'Crimson Text', serif;
font-size: 1.1rem;
}
.chat-input .v-field__input::placeholder {
color: #8c7e6a !important;
opacity: 1;
}
.chat-placeholder {
font-style: italic;
color: #8c7e6a;
text-align: center;
padding: 20px;
}
.message {
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 4px;
}
.message.user {
background: rgba(93, 64, 55, 0.1);
text-align: right;
color: #5d4037;
font-weight: bold;
}
.message.assistant {
background: transparent;
text-align: left;
color: #2c3e50;
border-left: 3px solid #556b2f;
}

View File

@@ -191,6 +191,7 @@
<script setup>
import '@/assets/css/gallery.css'
const recipes = ref([])
const loading = ref(true)
const showDetails = ref(false)

View File

@@ -12,11 +12,54 @@
cover
></v-img>
</div>
<p class="brand-subtitle">A Recipe Collection</p>
<p class="brand-subtitle">Recipe Creator and Recipe Uploader</p>
</header>
<v-divider class="mb-10 separator"></v-divider>
<v-row justify="center" class="mb-6">
<v-col cols="12" md="8">
<div class="chat-container">
<div class="section-header mb-4 d-flex align-center">
<v-icon icon="mdi-chef-hat" class="mr-2" size="small"></v-icon>
<span>Kitchen Consultation</span>
<v-spacer></v-spacer>
<v-btn
v-if="chatMessages.length > 0"
icon="mdi-delete-sweep-outline"
variant="text"
size="x-small"
color="#8c7e6a"
title="Clear Conversation"
@click="chatMessages = []"
></v-btn>
</div>
<div class="chat-display mb-4" ref="chatDisplay">
<div v-if="chatMessages.length === 0" class="chat-placeholder">
"What shall we create today?"
</div>
<div v-for="(msg, i) in chatMessages" :key="i" :class="['message', msg.role]">
<span class="message-text">{{ msg.text }}</span>
</div>
</div>
<v-text-field
v-model="userQuery"
variant="outlined"
hide-details
class="chat-input"
@keyup.enter="askChef"
:loading="chatLoading"
>
<template v-slot:append-inner>
<v-btn icon="mdi-send-variant" variant="text" size="small" color="#5d4037" @click="askChef"></v-btn>
</template>
</v-text-field>
</div>
</v-col>
</v-row>
<v-row justify="center" class="mb-12">
<v-col cols="12" md="8" class="d-flex flex-column align-center">
<div
@@ -147,7 +190,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import '@/assets/css/app-theme.css'
@@ -159,6 +202,10 @@ const recipe = ref(null)
const isDragging = ref(false)
const saving = ref(false)
const hasSaved = ref(false)
const userQuery = ref('')
const chatLoading = ref(false)
const chatMessages = ref([])
const chatDisplay = ref(null)
onMounted(() => {
const savedRecipe = localStorage.getItem('pending_recipe')
@@ -304,4 +351,49 @@ const clearAll = () => {
saving.value = false
localStorage.removeItem('pending_recipe')
}
const askChef = async () => {
if (!userQuery.value.trim()) return
const query = userQuery.value
chatMessages.value.push({ role: 'user', text: userQuery.value })
userQuery.value = ''
chatLoading.value = true
await nextTick()
scrollToBottom()
try {
const data = await $fetch(`${config.public.apiBase}api/recipe/consult`, {
method: 'POST',
body: { prompt: query }
})
chatMessages.value.push({ role: 'assistant', text: data.reply })
if (data.recipe && data.recipe.title) {
recipe.value = data.recipe
hasSaved.value = false
files.value = []
localStorage.removeItem('pending_recipe')
}
await nextTick()
scrollToBottom()
} catch (err) {
chatMessages.value.push({
role: 'assistant',
text: "The kitchen is currently closed for repairs. Try again in a moment?"
})
} finally {
chatLoading.value = false
}
}
const scrollToBottom = () => {
if (chatDisplay.value) {
chatDisplay.value.scrollTop = chatDisplay.value.scrollHeight
}
}
</script>

View File

@@ -81,6 +81,7 @@
<script setup>
import { ref } from 'vue'
const isLogin = ref(true)
const email = ref('')
const password = ref('')