diff --git a/package.json b/package.json index bdcbcaf..e34ccb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "howtocook-mcp", - "version": "0.0.1", + "version": "0.0.6", "type": "module", "main": "build/index.js", "bin": { diff --git a/src/data/recipes.ts b/src/data/recipes.ts new file mode 100644 index 0000000..76b3766 --- /dev/null +++ b/src/data/recipes.ts @@ -0,0 +1,35 @@ +import { Recipe } from '../types/index.js'; + +// 远程菜谱JSON文件URL +const RECIPES_URL = 'https://mp-bc8d1f0a-3356-4a4e-8592-f73a3371baa2.cdn.bspapp.com/all_recipes.json'; + +// 从远程URL获取数据的异步函数 +export async function fetchRecipes(): Promise { + try { + // 使用fetch API获取远程数据 + const response = await fetch(RECIPES_URL); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + // 解析JSON数据 + const data = await response.json(); + return data as Recipe[]; + } catch (error) { + console.error('获取远程菜谱数据失败:', error); + // 直接返回空数组,不尝试使用本地备份 + return []; + } +} + +// 获取所有分类 +export function getAllCategories(recipes: Recipe[]): string[] { + const categories = new Set(); + recipes.forEach((recipe) => { + if (recipe.category) { + categories.add(recipe.category); + } + }); + return Array.from(categories); +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index e0593a1..8c4a0a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,109 +2,16 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -// 导入zod验证库 -import { z } from "zod"; - -// 定义菜谱的类型接口 -interface Ingredient { - name: string; - quantity: number | null; - unit: string | null; - text_quantity: string; - notes: string; -} - -interface Step { - step: number; - description: string; -} - -interface Recipe { - id: string; - name: string; - description: string; - source_path: string; - image_path: string | null; - category: string; - difficulty: number; - tags: string[]; - servings: number; - ingredients: Ingredient[]; - steps: Step[]; - prep_time_minutes: number | null; - cook_time_minutes: number | null; - total_time_minutes: number | null; - additional_notes: string[]; -} - -// 添加简化版的Recipe接口,只包含id、name和description -interface SimpleRecipe { - id: string; - name: string; - description: string; - ingredients: { - name: string; - text_quantity: string; - }[]; -} - -// 更简化的Recipe接口,只包含name和description,用于getAllRecipes -interface NameOnlyRecipe { - name: string; - description: string; -} - -// 创建简化版的Recipe数据 -function simplifyRecipe(recipe: Recipe): SimpleRecipe { - return { - id: recipe.id, - name: recipe.name, - description: recipe.description, - ingredients: recipe.ingredients.map(ingredient => ({ - name: ingredient.name, - text_quantity: ingredient.text_quantity - })) - }; -} - -// 创建只包含name和description的Recipe数据 -function simplifyRecipeNameOnly(recipe: Recipe): NameOnlyRecipe { - return { - name: recipe.name, - description: recipe.description - }; -} - -// 远程菜谱JSON文件URL -const RECIPES_URL = 'https://mp-bc8d1f0a-3356-4a4e-8592-f73a3371baa2.cdn.bspapp.com/all_recipes.json'; - -// 读取菜谱数据 -let recipes: Recipe[] = []; - -// 从远程URL获取数据的异步函数 -async function fetchRecipes(): Promise { - try { - // 使用fetch API获取远程数据 - const response = await fetch(RECIPES_URL); - - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - - // 解析JSON数据 - const data = await response.json(); - return data as Recipe[]; - } catch (error) { - console.error('获取远程菜谱数据失败:', error); - // 直接返回空数组,不尝试使用本地备份 - return []; - } -} +import { fetchRecipes, getAllCategories } from "./data/recipes.js"; +import { registerGetAllRecipesTool } from "./tools/getAllRecipes.js"; +import { registerGetRecipesByCategoryTool } from "./tools/getRecipesByCategory.js"; +import { registerRecommendMealsTool } from "./tools/recommendMeals.js"; +import { registerWhatToEatTool } from "./tools/whatToEat.js"; // 创建MCP服务器 const server = new McpServer({ name: "howtocook-mcp", - version: "0.0.5", + version: "0.0.6", capabilities: { resources: {}, tools: {}, @@ -114,480 +21,22 @@ const server = new McpServer({ // 启动服务的主函数 export async function startServer() { // 获取菜谱数据 - recipes = await fetchRecipes(); + const recipes = await fetchRecipes(); // 确保我们读取到了菜谱数据 if (recipes.length === 0) { + console.error('无法获取菜谱数据,服务退出'); process.exit(1); } - // 获取所有分类 - const getAllCategories = () => { - const categories = new Set(); - recipes.forEach((recipe) => { - if (recipe.category) { - categories.add(recipe.category); - } - }); - return Array.from(categories); - }; + const categories = getAllCategories(recipes); - const categories = getAllCategories(); - - // Tool 1: 查询全部菜谱 - server.tool( - "mcp_howtocook_getAllRecipes", - "获取所有菜谱", - { - 'no_params': z.string().optional() - .describe('无参数') - }, // 使用可选参数提供描述 - async () => { - // 返回更简化版的菜谱数据,只包含name和description - const simplifiedRecipes = recipes.map(simplifyRecipeNameOnly); - return { - content: [ - { - type: "text", - text: JSON.stringify(simplifiedRecipes, null, 2), - }, - ], - }; - } - ); - - // Tool 2: 根据分类查询菜谱 - server.tool( - "mcp_howtocook_getRecipesByCategory", - `根据分类查询菜谱,可选分类有: ${categories.join(', ')}`, - { - category: z.enum(categories as [string, ...string[]]) - .describe('菜谱分类名称,如水产、早餐、荤菜、主食等') - }, - async ({ category }: { category: string }) => { - const filteredRecipes = recipes.filter((recipe) => recipe.category === category); - // 返回简化版的菜谱数据 - const simplifiedRecipes = filteredRecipes.map(simplifyRecipe); - return { - content: [ - { - type: "text", - text: JSON.stringify(simplifiedRecipes, null, 2), - }, - ], - }; - } - ); - - // Tool 3: 智能推荐菜谱 - server.tool( - "mcp_howtocook_recommendMeals", - "根据用户的忌口、过敏原、人数智能推荐菜谱,创建一周的膳食计划以及大致的购物清单", - { - allergies: z.array(z.string()).optional() - .describe('过敏原列表,如["大蒜", "虾"]'), - avoidItems: z.array(z.string()).optional() - .describe('忌口食材列表,如["葱", "姜"]'), - peopleCount: z.number().int().min(1).max(10) - .describe('用餐人数,1-10之间的整数') - }, - async ({ allergies = [], avoidItems = [], peopleCount }: { - allergies?: string[], - avoidItems?: string[], - peopleCount: number - }) => { - // 过滤掉含有忌口和过敏原的菜谱 - const filteredRecipes = recipes.filter((recipe) => { - // 检查是否包含过敏原或忌口食材 - const hasAllergiesOrAvoidItems = recipe.ingredients?.some((ingredient) => { - const name = ingredient.name?.toLowerCase() || ''; - return allergies.some(allergy => name.includes(allergy.toLowerCase())) || - avoidItems.some(item => name.includes(item.toLowerCase())); - }); - - return !hasAllergiesOrAvoidItems; - }); - - // 将菜谱按分类分组 - const recipesByCategory: Record = {}; - const targetCategories = ['水产', '早餐', '荤菜', '主食']; - - filteredRecipes.forEach((recipe) => { - if (targetCategories.includes(recipe.category)) { - if (!recipesByCategory[recipe.category]) { - recipesByCategory[recipe.category] = []; - } - recipesByCategory[recipe.category].push(recipe); - } - }); - - // 创建每周膳食计划 - const mealPlan: { - weekdays: Array<{ - day: string; - breakfast: SimpleRecipe[]; - lunch: SimpleRecipe[]; - dinner: SimpleRecipe[]; - }>; - weekend: Array<{ - day: string; - breakfast: SimpleRecipe[]; - lunch: SimpleRecipe[]; - dinner: SimpleRecipe[]; - }>; - groceryList: { - ingredients: Array<{ - name: string; - totalQuantity: number | null; - unit: string | null; - recipeCount: number; - recipes: string[]; - }>; - shoppingPlan: { - fresh: string[]; - pantry: string[]; - spices: string[]; - others: string[]; - }; - }; - } = { - weekdays: [], - weekend: [], - groceryList: { - ingredients: [], - shoppingPlan: { - fresh: [], - pantry: [], - spices: [], - others: [] - } - } - }; - - // 用于跟踪已经选择的菜谱,以便后续处理食材信息 - const selectedRecipes: Recipe[] = []; - - // 周一至周五 - for (let i = 0; i < 5; i++) { - const dayPlan = { - day: ['周一', '周二', '周三', '周四', '周五'][i], - breakfast: [] as SimpleRecipe[], - lunch: [] as SimpleRecipe[], - dinner: [] as SimpleRecipe[] - }; - - // 早餐 - 根据人数推荐1-2个早餐菜单 - const breakfastCount = Math.max(1, Math.ceil(peopleCount / 5)); - for (let j = 0; j < breakfastCount && recipesByCategory['早餐'] && recipesByCategory['早餐'].length > 0; j++) { - const breakfastIndex = Math.floor(Math.random() * recipesByCategory['早餐'].length); - const selectedRecipe = recipesByCategory['早餐'][breakfastIndex]; - selectedRecipes.push(selectedRecipe); - dayPlan.breakfast.push(simplifyRecipe(selectedRecipe)); - // 避免重复,从候选列表中移除 - recipesByCategory['早餐'] = recipesByCategory['早餐'].filter((_, idx) => idx !== breakfastIndex); - } - - // 午餐和晚餐的菜谱数量,根据人数确定 - const mealCount = Math.max(2, Math.ceil(peopleCount / 3)); - - // 午餐 - for (let j = 0; j < mealCount; j++) { - // 随机选择菜系:主食、水产、蔬菜、荤菜等 - const categories = ['主食', '水产', '荤菜', '素菜', '甜品']; - let selectedCategory = categories[Math.floor(Math.random() * categories.length)]; - - // 如果该分类没有菜谱或已用完,尝试其他分类 - while (!recipesByCategory[selectedCategory] || recipesByCategory[selectedCategory].length === 0) { - selectedCategory = categories[Math.floor(Math.random() * categories.length)]; - if (categories.every(cat => !recipesByCategory[cat] || recipesByCategory[cat].length === 0)) { - break; // 所有分类都没有可用菜谱,退出循环 - } - } - - if (recipesByCategory[selectedCategory] && recipesByCategory[selectedCategory].length > 0) { - const index = Math.floor(Math.random() * recipesByCategory[selectedCategory].length); - const selectedRecipe = recipesByCategory[selectedCategory][index]; - selectedRecipes.push(selectedRecipe); - dayPlan.lunch.push(simplifyRecipe(selectedRecipe)); - // 避免重复,从候选列表中移除 - recipesByCategory[selectedCategory] = recipesByCategory[selectedCategory].filter((_, idx) => idx !== index); - } - } - - // 晚餐 - for (let j = 0; j < mealCount; j++) { - // 随机选择菜系,与午餐类似但可添加汤羹 - const categories = ['主食', '水产', '荤菜', '素菜', '甜品', '汤羹']; - let selectedCategory = categories[Math.floor(Math.random() * categories.length)]; - - // 如果该分类没有菜谱或已用完,尝试其他分类 - while (!recipesByCategory[selectedCategory] || recipesByCategory[selectedCategory].length === 0) { - selectedCategory = categories[Math.floor(Math.random() * categories.length)]; - if (categories.every(cat => !recipesByCategory[cat] || recipesByCategory[cat].length === 0)) { - break; // 所有分类都没有可用菜谱,退出循环 - } - } - - if (recipesByCategory[selectedCategory] && recipesByCategory[selectedCategory].length > 0) { - const index = Math.floor(Math.random() * recipesByCategory[selectedCategory].length); - const selectedRecipe = recipesByCategory[selectedCategory][index]; - selectedRecipes.push(selectedRecipe); - dayPlan.dinner.push(simplifyRecipe(selectedRecipe)); - // 避免重复,从候选列表中移除 - recipesByCategory[selectedCategory] = recipesByCategory[selectedCategory].filter((_, idx) => idx !== index); - } - } - - mealPlan.weekdays.push(dayPlan); - } - - // 周六和周日 - for (let i = 0; i < 2; i++) { - const dayPlan = { - day: ['周六', '周日'][i], - breakfast: [] as SimpleRecipe[], - lunch: [] as SimpleRecipe[], - dinner: [] as SimpleRecipe[] - }; - - // 早餐 - 根据人数推荐菜品,至少2个菜品,随人数增加 - const breakfastCount = Math.max(2, Math.ceil(peopleCount / 3)); - for (let j = 0; j < breakfastCount && recipesByCategory['早餐'] && recipesByCategory['早餐'].length > 0; j++) { - const breakfastIndex = Math.floor(Math.random() * recipesByCategory['早餐'].length); - const selectedRecipe = recipesByCategory['早餐'][breakfastIndex]; - selectedRecipes.push(selectedRecipe); - dayPlan.breakfast.push(simplifyRecipe(selectedRecipe)); - recipesByCategory['早餐'] = recipesByCategory['早餐'].filter((_, idx) => idx !== breakfastIndex); - } - - // 计算工作日的基础菜品数量 - const weekdayMealCount = Math.max(2, Math.ceil(peopleCount / 3)); - // 周末菜品数量:比工作日多1-2个菜,随人数增加 - const weekendAddition = peopleCount <= 4 ? 1 : 2; // 4人以下多1个菜,4人以上多2个菜 - const mealCount = weekdayMealCount + weekendAddition; - - const getMeals = (count: number): SimpleRecipe[] => { - const result: SimpleRecipe[] = []; - const categories = ['荤菜', '水产']; - - // 尽量平均分配不同分类的菜品 - for (let j = 0; j < count; j++) { - const category = categories[j % categories.length]; - if (recipesByCategory[category] && recipesByCategory[category].length > 0) { - const index = Math.floor(Math.random() * recipesByCategory[category].length); - const selectedRecipe = recipesByCategory[category][index]; - selectedRecipes.push(selectedRecipe); - result.push(simplifyRecipe(selectedRecipe)); - recipesByCategory[category] = recipesByCategory[category].filter((_, idx) => idx !== index); - } else if (recipesByCategory['主食'] && recipesByCategory['主食'].length > 0) { - // 如果没有足够的荤菜或水产,使用主食 - const index = Math.floor(Math.random() * recipesByCategory['主食'].length); - const selectedRecipe = recipesByCategory['主食'][index]; - selectedRecipes.push(selectedRecipe); - result.push(simplifyRecipe(selectedRecipe)); - recipesByCategory['主食'] = recipesByCategory['主食'].filter((_, idx) => idx !== index); - } - } - - return result; - }; - - dayPlan.lunch = getMeals(mealCount); - dayPlan.dinner = getMeals(mealCount); - - mealPlan.weekend.push(dayPlan); - } - - // 统计食材清单,收集所有菜谱的所有食材 - const ingredientMap = new Map(); - - // 处理一个菜谱的所有食材 - const processRecipeIngredients = (recipe: Recipe) => { - recipe.ingredients?.forEach(ingredient => { - const key = ingredient.name.toLowerCase(); - - if (!ingredientMap.has(key)) { - ingredientMap.set(key, { - totalQuantity: ingredient.quantity, - unit: ingredient.unit, - recipeCount: 1, - recipes: [recipe.name] - }); - } else { - const existing = ingredientMap.get(key)!; - - // 对于有明确数量和单位的食材,进行汇总 - if (existing.unit && ingredient.unit && existing.unit === ingredient.unit && existing.totalQuantity !== null && ingredient.quantity !== null) { - existing.totalQuantity += ingredient.quantity; - } else { - // 否则保留 null,表示数量不确定 - existing.totalQuantity = null; - existing.unit = null; - } - - existing.recipeCount += 1; - if (!existing.recipes.includes(recipe.name)) { - existing.recipes.push(recipe.name); - } - } - }); - }; - - // 处理所有菜谱 - // 使用完整的Recipe对象处理食材信息 - selectedRecipes.forEach(processRecipeIngredients); - - // 整理食材清单 - for (const [name, info] of ingredientMap.entries()) { - mealPlan.groceryList.ingredients.push({ - name, - totalQuantity: info.totalQuantity, - unit: info.unit, - recipeCount: info.recipeCount, - recipes: info.recipes - }); - } - - // 对食材按使用频率排序 - mealPlan.groceryList.ingredients.sort((a, b) => b.recipeCount - a.recipeCount); - - // 生成购物计划,根据食材类型进行分类 - const spiceKeywords = ['盐', '糖', '酱油', '醋', '料酒', '香料', '胡椒', '孜然', '辣椒', '花椒', '姜', '蒜', '葱', '调味']; - const freshKeywords = ['肉', '鱼', '虾', '蛋', '奶', '菜', '菠菜', '白菜', '青菜', '豆腐', '生菜', '水产', '豆芽', '西红柿', '番茄', '水果', '香菇', '木耳', '蘑菇']; - const pantryKeywords = ['米', '面', '粉', '油', '酒', '醋', '糖', '盐', '酱', '豆', '干', '罐头', '方便面', '面条', '米饭', '意大利面', '燕麦']; - - mealPlan.groceryList.ingredients.forEach(ingredient => { - const name = ingredient.name.toLowerCase(); - - if (spiceKeywords.some(keyword => name.includes(keyword))) { - mealPlan.groceryList.shoppingPlan.spices.push(ingredient.name); - } else if (freshKeywords.some(keyword => name.includes(keyword))) { - mealPlan.groceryList.shoppingPlan.fresh.push(ingredient.name); - } else if (pantryKeywords.some(keyword => name.includes(keyword))) { - mealPlan.groceryList.shoppingPlan.pantry.push(ingredient.name); - } else { - mealPlan.groceryList.shoppingPlan.others.push(ingredient.name); - } - }); - - return { - content: [ - { - type: "text", - text: JSON.stringify(mealPlan, null, 2), - }, - ], - }; - } - ); - - // Tool 4: 不知道吃什么,根据人数直接推荐 - server.tool( - "mcp_howtocook_whatToEat", - "不知道吃什么?根据人数直接推荐适合的菜品组合", - { - peopleCount: z.number().int().min(1).max(10) - .describe('用餐人数,1-10之间的整数,会根据人数推荐合适数量的菜品') - }, - async ({ peopleCount }: { peopleCount: number }) => { - // 根据人数计算荤素菜数量 - const vegetableCount = Math.floor((peopleCount + 1) / 2); - const meatCount = Math.ceil((peopleCount + 1) / 2); - - // 获取所有荤菜 - let meatDishes = recipes.filter((recipe) => - recipe.category === '荤菜' || recipe.category === '水产' - ); - - // 获取其他可能的菜品(当做素菜) - let vegetableDishes = recipes.filter((recipe) => - recipe.category !== '荤菜' && recipe.category !== '水产' && - recipe.category !== '早餐' && recipe.category !== '主食' - ); - - // 特别处理:如果人数超过8人,增加鱼类荤菜 - let recommendedDishes: Recipe[] = []; - let fishDish: Recipe | null = null; - - if (peopleCount > 8) { - const fishDishes = recipes.filter((recipe) => recipe.category === '水产'); - if (fishDishes.length > 0) { - fishDish = fishDishes[Math.floor(Math.random() * fishDishes.length)]; - recommendedDishes.push(fishDish); - } - } - - // 按照不同肉类的优先级选择荤菜 - const meatTypes = ['猪肉', '鸡肉', '牛肉', '羊肉', '鸭肉', '鱼肉']; - const selectedMeatDishes: Recipe[] = []; - - // 需要选择的荤菜数量 - const remainingMeatCount = fishDish ? meatCount - 1 : meatCount; - - // 尝试按照肉类优先级选择荤菜 - for (const meatType of meatTypes) { - if (selectedMeatDishes.length >= remainingMeatCount) break; - - const meatTypeOptions = meatDishes.filter((dish) => { - // 检查菜品的材料是否包含这种肉类 - return dish.ingredients?.some((ingredient) => { - const name = ingredient.name?.toLowerCase() || ''; - return name.includes(meatType.toLowerCase()); - }); - }); - - if (meatTypeOptions.length > 0) { - // 随机选择一道这种肉类的菜 - const selected = meatTypeOptions[Math.floor(Math.random() * meatTypeOptions.length)]; - selectedMeatDishes.push(selected); - // 从可选列表中移除,避免重复选择 - meatDishes = meatDishes.filter((dish) => dish.id !== selected.id); - } - } - - // 如果通过肉类筛选的荤菜不够,随机选择剩余的 - while (selectedMeatDishes.length < remainingMeatCount && meatDishes.length > 0) { - const randomIndex = Math.floor(Math.random() * meatDishes.length); - selectedMeatDishes.push(meatDishes[randomIndex]); - meatDishes.splice(randomIndex, 1); - } - - // 随机选择素菜 - const selectedVegetableDishes: Recipe[] = []; - while (selectedVegetableDishes.length < vegetableCount && vegetableDishes.length > 0) { - const randomIndex = Math.floor(Math.random() * vegetableDishes.length); - selectedVegetableDishes.push(vegetableDishes[randomIndex]); - vegetableDishes.splice(randomIndex, 1); - } - - // 合并推荐菜单 - recommendedDishes = recommendedDishes.concat(selectedMeatDishes, selectedVegetableDishes); - - // 为人数多的情况考虑增加甜味菜品 - const recommendationDetails = { - peopleCount, - meatDishCount: meatCount, - vegetableDishCount: vegetableCount, - dishes: recommendedDishes.map(simplifyRecipe), - message: `为${peopleCount}人推荐的菜品,包含${selectedMeatDishes.length}个荤菜和${selectedVegetableDishes.length}个素菜。` - }; - - return { - content: [ - { - type: "text", - text: JSON.stringify(recommendationDetails, null, 2), - }, - ], - }; - } - ); + // 注册所有工具 + registerGetAllRecipesTool(server, recipes); + registerGetRecipesByCategoryTool(server, recipes, categories); + registerRecommendMealsTool(server, recipes); + registerWhatToEatTool(server, recipes); // 启动MCP服务器 const transport = new StdioServerTransport(); diff --git a/src/tools/getAllRecipes.ts b/src/tools/getAllRecipes.ts new file mode 100644 index 0000000..3842ed3 --- /dev/null +++ b/src/tools/getAllRecipes.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { Recipe } from "../types/index.js"; +import { simplifyRecipeNameOnly } from "../utils/recipeUtils.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +export function registerGetAllRecipesTool(server: McpServer, recipes: Recipe[]) { + server.tool( + "mcp_howtocook_getAllRecipes", + "获取所有菜谱", + { + 'no_param': z.string().optional() + .describe('无参数') + }, + async () => { + // 返回更简化版的菜谱数据,只包含name和description + const simplifiedRecipes = recipes.map(simplifyRecipeNameOnly); + return { + content: [ + { + type: "text", + text: JSON.stringify(simplifiedRecipes, null, 2), + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/src/tools/getRecipesByCategory.ts b/src/tools/getRecipesByCategory.ts new file mode 100644 index 0000000..88ac8c8 --- /dev/null +++ b/src/tools/getRecipesByCategory.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { Recipe } from "../types/index.js"; +import { simplifyRecipe } from "../utils/recipeUtils.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +export function registerGetRecipesByCategoryTool(server: McpServer, recipes: Recipe[], categories: string[]) { + server.tool( + "mcp_howtocook_getRecipesByCategory", + `根据分类查询菜谱,可选分类有: ${categories.join(', ')}`, + { + category: z.enum(categories as [string, ...string[]]) + .describe('菜谱分类名称,如水产、早餐、荤菜、主食等') + }, + async ({ category }: { category: string }) => { + const filteredRecipes = recipes.filter((recipe) => recipe.category === category); + // 返回简化版的菜谱数据 + const simplifiedRecipes = filteredRecipes.map(simplifyRecipe); + return { + content: [ + { + type: "text", + text: JSON.stringify(simplifiedRecipes, null, 2), + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/src/tools/recommendMeals.ts b/src/tools/recommendMeals.ts new file mode 100644 index 0000000..c65a4a6 --- /dev/null +++ b/src/tools/recommendMeals.ts @@ -0,0 +1,235 @@ +import { z } from "zod"; +import { Recipe, MealPlan, SimpleRecipe, DayPlan } from "../types/index.js"; +import { simplifyRecipe, processRecipeIngredients, categorizeIngredients } from "../utils/recipeUtils.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +export function registerRecommendMealsTool(server: McpServer, recipes: Recipe[]) { + server.tool( + "mcp_howtocook_recommendMeals", + "根据用户的忌口、过敏原、人数智能推荐菜谱,创建一周的膳食计划以及大致的购物清单", + { + allergies: z.array(z.string()).optional() + .describe('过敏原列表,如["大蒜", "虾"]'), + avoidItems: z.array(z.string()).optional() + .describe('忌口食材列表,如["葱", "姜"]'), + peopleCount: z.number().int().min(1).max(10) + .describe('用餐人数,1-10之间的整数') + }, + async ({ allergies = [], avoidItems = [], peopleCount }: { + allergies?: string[], + avoidItems?: string[], + peopleCount: number + }) => { + // 过滤掉含有忌口和过敏原的菜谱 + const filteredRecipes = recipes.filter((recipe) => { + // 检查是否包含过敏原或忌口食材 + const hasAllergiesOrAvoidItems = recipe.ingredients?.some((ingredient) => { + const name = ingredient.name?.toLowerCase() || ''; + return allergies.some(allergy => name.includes(allergy.toLowerCase())) || + avoidItems.some(item => name.includes(item.toLowerCase())); + }); + + return !hasAllergiesOrAvoidItems; + }); + + // 将菜谱按分类分组 + const recipesByCategory: Record = {}; + const targetCategories = ['水产', '早餐', '荤菜', '主食']; + + filteredRecipes.forEach((recipe) => { + if (targetCategories.includes(recipe.category)) { + if (!recipesByCategory[recipe.category]) { + recipesByCategory[recipe.category] = []; + } + recipesByCategory[recipe.category].push(recipe); + } + }); + + // 创建每周膳食计划 + const mealPlan: MealPlan = { + weekdays: [], + weekend: [], + groceryList: { + ingredients: [], + shoppingPlan: { + fresh: [], + pantry: [], + spices: [], + others: [] + } + } + }; + + // 用于跟踪已经选择的菜谱,以便后续处理食材信息 + const selectedRecipes: Recipe[] = []; + + // 周一至周五 + for (let i = 0; i < 5; i++) { + const dayPlan: DayPlan = { + day: ['周一', '周二', '周三', '周四', '周五'][i], + breakfast: [], + lunch: [], + dinner: [] + }; + + // 早餐 - 根据人数推荐1-2个早餐菜单 + const breakfastCount = Math.max(1, Math.ceil(peopleCount / 5)); + for (let j = 0; j < breakfastCount && recipesByCategory['早餐'] && recipesByCategory['早餐'].length > 0; j++) { + const breakfastIndex = Math.floor(Math.random() * recipesByCategory['早餐'].length); + const selectedRecipe = recipesByCategory['早餐'][breakfastIndex]; + selectedRecipes.push(selectedRecipe); + dayPlan.breakfast.push(simplifyRecipe(selectedRecipe)); + // 避免重复,从候选列表中移除 + recipesByCategory['早餐'] = recipesByCategory['早餐'].filter((_, idx) => idx !== breakfastIndex); + } + + // 午餐和晚餐的菜谱数量,根据人数确定 + const mealCount = Math.max(2, Math.ceil(peopleCount / 3)); + + // 午餐 + for (let j = 0; j < mealCount; j++) { + // 随机选择菜系:主食、水产、蔬菜、荤菜等 + const categories = ['主食', '水产', '荤菜', '素菜', '甜品']; + let selectedCategory = categories[Math.floor(Math.random() * categories.length)]; + + // 如果该分类没有菜谱或已用完,尝试其他分类 + while (!recipesByCategory[selectedCategory] || recipesByCategory[selectedCategory].length === 0) { + selectedCategory = categories[Math.floor(Math.random() * categories.length)]; + if (categories.every(cat => !recipesByCategory[cat] || recipesByCategory[cat].length === 0)) { + break; // 所有分类都没有可用菜谱,退出循环 + } + } + + if (recipesByCategory[selectedCategory] && recipesByCategory[selectedCategory].length > 0) { + const index = Math.floor(Math.random() * recipesByCategory[selectedCategory].length); + const selectedRecipe = recipesByCategory[selectedCategory][index]; + selectedRecipes.push(selectedRecipe); + dayPlan.lunch.push(simplifyRecipe(selectedRecipe)); + // 避免重复,从候选列表中移除 + recipesByCategory[selectedCategory] = recipesByCategory[selectedCategory].filter((_, idx) => idx !== index); + } + } + + // 晚餐 + for (let j = 0; j < mealCount; j++) { + // 随机选择菜系,与午餐类似但可添加汤羹 + const categories = ['主食', '水产', '荤菜', '素菜', '甜品', '汤羹']; + let selectedCategory = categories[Math.floor(Math.random() * categories.length)]; + + // 如果该分类没有菜谱或已用完,尝试其他分类 + while (!recipesByCategory[selectedCategory] || recipesByCategory[selectedCategory].length === 0) { + selectedCategory = categories[Math.floor(Math.random() * categories.length)]; + if (categories.every(cat => !recipesByCategory[cat] || recipesByCategory[cat].length === 0)) { + break; // 所有分类都没有可用菜谱,退出循环 + } + } + + if (recipesByCategory[selectedCategory] && recipesByCategory[selectedCategory].length > 0) { + const index = Math.floor(Math.random() * recipesByCategory[selectedCategory].length); + const selectedRecipe = recipesByCategory[selectedCategory][index]; + selectedRecipes.push(selectedRecipe); + dayPlan.dinner.push(simplifyRecipe(selectedRecipe)); + // 避免重复,从候选列表中移除 + recipesByCategory[selectedCategory] = recipesByCategory[selectedCategory].filter((_, idx) => idx !== index); + } + } + + mealPlan.weekdays.push(dayPlan); + } + + // 周六和周日 + for (let i = 0; i < 2; i++) { + const dayPlan: DayPlan = { + day: ['周六', '周日'][i], + breakfast: [], + lunch: [], + dinner: [] + }; + + // 早餐 - 根据人数推荐菜品,至少2个菜品,随人数增加 + const breakfastCount = Math.max(2, Math.ceil(peopleCount / 3)); + for (let j = 0; j < breakfastCount && recipesByCategory['早餐'] && recipesByCategory['早餐'].length > 0; j++) { + const breakfastIndex = Math.floor(Math.random() * recipesByCategory['早餐'].length); + const selectedRecipe = recipesByCategory['早餐'][breakfastIndex]; + selectedRecipes.push(selectedRecipe); + dayPlan.breakfast.push(simplifyRecipe(selectedRecipe)); + recipesByCategory['早餐'] = recipesByCategory['早餐'].filter((_, idx) => idx !== breakfastIndex); + } + + // 计算工作日的基础菜品数量 + const weekdayMealCount = Math.max(2, Math.ceil(peopleCount / 3)); + // 周末菜品数量:比工作日多1-2个菜,随人数增加 + const weekendAddition = peopleCount <= 4 ? 1 : 2; // 4人以下多1个菜,4人以上多2个菜 + const mealCount = weekdayMealCount + weekendAddition; + + const getMeals = (count: number): SimpleRecipe[] => { + const result: SimpleRecipe[] = []; + const categories = ['荤菜', '水产']; + + // 尽量平均分配不同分类的菜品 + for (let j = 0; j < count; j++) { + const category = categories[j % categories.length]; + if (recipesByCategory[category] && recipesByCategory[category].length > 0) { + const index = Math.floor(Math.random() * recipesByCategory[category].length); + const selectedRecipe = recipesByCategory[category][index]; + selectedRecipes.push(selectedRecipe); + result.push(simplifyRecipe(selectedRecipe)); + recipesByCategory[category] = recipesByCategory[category].filter((_, idx) => idx !== index); + } else if (recipesByCategory['主食'] && recipesByCategory['主食'].length > 0) { + // 如果没有足够的荤菜或水产,使用主食 + const index = Math.floor(Math.random() * recipesByCategory['主食'].length); + const selectedRecipe = recipesByCategory['主食'][index]; + selectedRecipes.push(selectedRecipe); + result.push(simplifyRecipe(selectedRecipe)); + recipesByCategory['主食'] = recipesByCategory['主食'].filter((_, idx) => idx !== index); + } + } + + return result; + }; + + dayPlan.lunch = getMeals(mealCount); + dayPlan.dinner = getMeals(mealCount); + + mealPlan.weekend.push(dayPlan); + } + + // 统计食材清单,收集所有菜谱的所有食材 + const ingredientMap = new Map(); + + // 处理所有菜谱 + selectedRecipes.forEach(recipe => processRecipeIngredients(recipe, ingredientMap)); + + // 整理食材清单 + for (const [name, info] of ingredientMap.entries()) { + mealPlan.groceryList.ingredients.push({ + name, + totalQuantity: info.totalQuantity, + unit: info.unit, + recipeCount: info.recipeCount, + recipes: info.recipes + }); + } + + // 对食材按使用频率排序 + mealPlan.groceryList.ingredients.sort((a, b) => b.recipeCount - a.recipeCount); + + // 生成购物计划,根据食材类型进行分类 + categorizeIngredients(mealPlan.groceryList.ingredients, mealPlan.groceryList.shoppingPlan); + + return { + content: [ + { + type: "text", + text: JSON.stringify(mealPlan, null, 2), + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/src/tools/whatToEat.ts b/src/tools/whatToEat.ts new file mode 100644 index 0000000..a6363be --- /dev/null +++ b/src/tools/whatToEat.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; +import { Recipe, DishRecommendation } from "../types/index.js"; +import { simplifyRecipe } from "../utils/recipeUtils.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +export function registerWhatToEatTool(server: McpServer, recipes: Recipe[]) { + server.tool( + "mcp_howtocook_whatToEat", + "不知道吃什么?根据人数直接推荐适合的菜品组合", + { + peopleCount: z.number().int().min(1).max(10) + .describe('用餐人数,1-10之间的整数,会根据人数推荐合适数量的菜品') + }, + async ({ peopleCount }: { peopleCount: number }) => { + // 根据人数计算荤素菜数量 + const vegetableCount = Math.floor((peopleCount + 1) / 2); + const meatCount = Math.ceil((peopleCount + 1) / 2); + + // 获取所有荤菜 + let meatDishes = recipes.filter((recipe) => + recipe.category === '荤菜' || recipe.category === '水产' + ); + + // 获取其他可能的菜品(当做素菜) + let vegetableDishes = recipes.filter((recipe) => + recipe.category !== '荤菜' && recipe.category !== '水产' && + recipe.category !== '早餐' && recipe.category !== '主食' + ); + + // 特别处理:如果人数超过8人,增加鱼类荤菜 + let recommendedDishes: Recipe[] = []; + let fishDish: Recipe | null = null; + + if (peopleCount > 8) { + const fishDishes = recipes.filter((recipe) => recipe.category === '水产'); + if (fishDishes.length > 0) { + fishDish = fishDishes[Math.floor(Math.random() * fishDishes.length)]; + recommendedDishes.push(fishDish); + } + } + + // 按照不同肉类的优先级选择荤菜 + const meatTypes = ['猪肉', '鸡肉', '牛肉', '羊肉', '鸭肉', '鱼肉']; + const selectedMeatDishes: Recipe[] = []; + + // 需要选择的荤菜数量 + const remainingMeatCount = fishDish ? meatCount - 1 : meatCount; + + // 尝试按照肉类优先级选择荤菜 + for (const meatType of meatTypes) { + if (selectedMeatDishes.length >= remainingMeatCount) break; + + const meatTypeOptions = meatDishes.filter((dish) => { + // 检查菜品的材料是否包含这种肉类 + return dish.ingredients?.some((ingredient) => { + const name = ingredient.name?.toLowerCase() || ''; + return name.includes(meatType.toLowerCase()); + }); + }); + + if (meatTypeOptions.length > 0) { + // 随机选择一道这种肉类的菜 + const selected = meatTypeOptions[Math.floor(Math.random() * meatTypeOptions.length)]; + selectedMeatDishes.push(selected); + // 从可选列表中移除,避免重复选择 + meatDishes = meatDishes.filter((dish) => dish.id !== selected.id); + } + } + + // 如果通过肉类筛选的荤菜不够,随机选择剩余的 + while (selectedMeatDishes.length < remainingMeatCount && meatDishes.length > 0) { + const randomIndex = Math.floor(Math.random() * meatDishes.length); + selectedMeatDishes.push(meatDishes[randomIndex]); + meatDishes.splice(randomIndex, 1); + } + + // 随机选择素菜 + const selectedVegetableDishes: Recipe[] = []; + while (selectedVegetableDishes.length < vegetableCount && vegetableDishes.length > 0) { + const randomIndex = Math.floor(Math.random() * vegetableDishes.length); + selectedVegetableDishes.push(vegetableDishes[randomIndex]); + vegetableDishes.splice(randomIndex, 1); + } + + // 合并推荐菜单 + recommendedDishes = recommendedDishes.concat(selectedMeatDishes, selectedVegetableDishes); + + // 构建推荐结果 + const recommendationDetails: DishRecommendation = { + peopleCount, + meatDishCount: selectedMeatDishes.length + (fishDish ? 1 : 0), + vegetableDishCount: selectedVegetableDishes.length, + dishes: recommendedDishes.map(simplifyRecipe), + message: `为${peopleCount}人推荐的菜品,包含${selectedMeatDishes.length + (fishDish ? 1 : 0)}个荤菜和${selectedVegetableDishes.length}个素菜。` + }; + + return { + content: [ + { + type: "text", + text: JSON.stringify(recommendationDetails, null, 2), + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..decbc89 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,87 @@ +// 定义菜谱的类型接口 +export interface Ingredient { + name: string; + quantity: number | null; + unit: string | null; + text_quantity: string; + notes: string; +} + +export interface Step { + step: number; + description: string; +} + +export interface Recipe { + id: string; + name: string; + description: string; + source_path: string; + image_path: string | null; + category: string; + difficulty: number; + tags: string[]; + servings: number; + ingredients: Ingredient[]; + steps: Step[]; + prep_time_minutes: number | null; + cook_time_minutes: number | null; + total_time_minutes: number | null; + additional_notes: string[]; +} + +// 添加简化版的Recipe接口,只包含id、name和description +export interface SimpleRecipe { + id: string; + name: string; + description: string; + ingredients: { + name: string; + text_quantity: string; + }[]; +} + +// 更简化的Recipe接口,只包含name和description,用于getAllRecipes +export interface NameOnlyRecipe { + name: string; + description: string; +} + +// 定义膳食计划相关接口 +export interface MealPlan { + weekdays: Array; + weekend: Array; + groceryList: GroceryList; +} + +export interface DayPlan { + day: string; + breakfast: SimpleRecipe[]; + lunch: SimpleRecipe[]; + dinner: SimpleRecipe[]; +} + +export interface GroceryList { + ingredients: Array<{ + name: string; + totalQuantity: number | null; + unit: string | null; + recipeCount: number; + recipes: string[]; + }>; + shoppingPlan: { + fresh: string[]; + pantry: string[]; + spices: string[]; + others: string[]; + }; +} + +// 定义推荐菜品的接口 +export interface DishRecommendation { + peopleCount: number; + meatDishCount: number; + vegetableDishCount: number; + dishes: SimpleRecipe[]; + message: string; +} \ No newline at end of file diff --git a/src/utils/recipeUtils.ts b/src/utils/recipeUtils.ts new file mode 100644 index 0000000..bef44e6 --- /dev/null +++ b/src/utils/recipeUtils.ts @@ -0,0 +1,91 @@ +import { Recipe, SimpleRecipe, NameOnlyRecipe, Ingredient } from '../types/index.js'; + +// 创建简化版的Recipe数据 +export function simplifyRecipe(recipe: Recipe): SimpleRecipe { + return { + id: recipe.id, + name: recipe.name, + description: recipe.description, + ingredients: recipe.ingredients.map((ingredient: Ingredient) => ({ + name: ingredient.name, + text_quantity: ingredient.text_quantity + })) + }; +} + +// 创建只包含name和description的Recipe数据 +export function simplifyRecipeNameOnly(recipe: Recipe): NameOnlyRecipe { + return { + name: recipe.name, + description: recipe.description + }; +} + +// 处理食材清单,收集菜谱的所有食材 +export function processRecipeIngredients(recipe: Recipe, ingredientMap: Map) { + recipe.ingredients?.forEach((ingredient: Ingredient) => { + const key = ingredient.name.toLowerCase(); + + if (!ingredientMap.has(key)) { + ingredientMap.set(key, { + totalQuantity: ingredient.quantity, + unit: ingredient.unit, + recipeCount: 1, + recipes: [recipe.name] + }); + } else { + const existing = ingredientMap.get(key)!; + + // 对于有明确数量和单位的食材,进行汇总 + if (existing.unit && ingredient.unit && existing.unit === ingredient.unit && existing.totalQuantity !== null && ingredient.quantity !== null) { + existing.totalQuantity += ingredient.quantity; + } else { + // 否则保留 null,表示数量不确定 + existing.totalQuantity = null; + existing.unit = null; + } + + existing.recipeCount += 1; + if (!existing.recipes.includes(recipe.name)) { + existing.recipes.push(recipe.name); + } + } + }); +} + +// 根据食材类型进行分类 +export function categorizeIngredients(ingredients: Array<{ + name: string, + totalQuantity: number | null, + unit: string | null, + recipeCount: number, + recipes: string[] +}>, shoppingPlan: { + fresh: string[], + pantry: string[], + spices: string[], + others: string[] +}) { + const spiceKeywords = ['盐', '糖', '酱油', '醋', '料酒', '香料', '胡椒', '孜然', '辣椒', '花椒', '姜', '蒜', '葱', '调味']; + const freshKeywords = ['肉', '鱼', '虾', '蛋', '奶', '菜', '菠菜', '白菜', '青菜', '豆腐', '生菜', '水产', '豆芽', '西红柿', '番茄', '水果', '香菇', '木耳', '蘑菇']; + const pantryKeywords = ['米', '面', '粉', '油', '酒', '醋', '糖', '盐', '酱', '豆', '干', '罐头', '方便面', '面条', '米饭', '意大利面', '燕麦']; + + ingredients.forEach(ingredient => { + const name = ingredient.name.toLowerCase(); + + if (spiceKeywords.some(keyword => name.includes(keyword))) { + shoppingPlan.spices.push(ingredient.name); + } else if (freshKeywords.some(keyword => name.includes(keyword))) { + shoppingPlan.fresh.push(ingredient.name); + } else if (pantryKeywords.some(keyword => name.includes(keyword))) { + shoppingPlan.pantry.push(ingredient.name); + } else { + shoppingPlan.others.push(ingredient.name); + } + }); +} \ No newline at end of file