feat: add generate cook & intro

This commit is contained in:
YunYouJun
2024-03-22 19:55:33 +08:00
parent a80d9163b8
commit 5efd7b0533
16 changed files with 3940 additions and 2578 deletions

View File

@@ -1 +1,2 @@
SD_API_BASE_URL=
OPENAI_API_KEY=

View File

@@ -3,7 +3,8 @@ import type { StuffItem } from '~/types'
import { meat, staple, vegetable } from '~/data/food'
import { useEmojiAnimation } from '~/composables/animation'
import { getRecipeImage } from '~/utils/api'
import type { AIRecipeInfo } from '~/packages/ai/src'
import { generateRecipeInfo, getRecipeImage } from '~/utils/api'
const rStore = useRecipeStore()
const curStuff = computed(() => rStore.selectedStuff)
@@ -36,11 +37,28 @@ function toggleStuff(item: StuffItem, category = '', _e?: Event) {
// cook recipe
const cooking = ref(false)
const recipeImg = ref('')
const aiRecipeInfo = ref<AIRecipeInfo>({
名称: '名称',
介绍: '介绍',
})
async function cook() {
cooking.value = true
const foods = rStore.selectedStuff
const img = await getRecipeImage(foods)
// reset
aiRecipeInfo.value = ({
名称: '起名中...',
介绍: '正在思考怎么介绍...',
})
recipeImg.value = ''
// generate
const [info, img] = await Promise.all([generateRecipeInfo(foods), getRecipeImage(foods)])
aiRecipeInfo.value = info
recipeImg.value = img
cooking.value = false
}
</script>
@@ -85,7 +103,7 @@ async function cook() {
</div>
<div m="y-4">
<h2 opacity="90" text="base" font="bold" p="1">
🍚 主食也要一起下锅吗不选也行
🍚 主食
</h2>
<div>
<StapleTag
@@ -130,7 +148,7 @@ async function cook() {
@click="cook()"
>
<div v-if="cooking" class="mr-2 inline-flex" i-svg-spinners:clock />
<span>美食 🥘</span>
<span>黑暗料理 🥘</span>
</button>
<div
@@ -139,15 +157,21 @@ async function cook() {
bg="gray-400/8"
>
<div text="xl" font="bold" p="1">
🍲 来看看制作出的美食吧
{{ aiRecipeInfo['名称'] }}
</div>
<div class="cook-recipes text-center" p="2">
<img
v-if="recipeImg"
class="m-auto w-25 rounded shadow transition hover:shadow-md"
:src="recipeImg"
alt="recipes"
>
<div v-else class="m-auto h-25 w-25 rounded bg-gray shadow transition hover:shadow-md" />
</div>
<div>
{{ aiRecipeInfo['介绍'] }}
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@
"type": "module",
"version": "1.2.2",
"private": true,
"packageManager": "pnpm@8.14.3",
"packageManager": "pnpm@8.15.5",
"engines": {
"node": ">=16"
},
@@ -28,46 +28,46 @@
"vue-about-me": "^1.2.7"
},
"devDependencies": {
"@antfu/eslint-config": "^2.6.3",
"@headlessui/vue": "^1.7.17",
"@iconify-json/carbon": "^1.1.28",
"@antfu/eslint-config": "^2.9.0",
"@headlessui/vue": "^1.7.19",
"@iconify-json/carbon": "^1.1.31",
"@iconify-json/fe": "^1.1.10",
"@iconify-json/gg": "^1.1.9",
"@iconify-json/ic": "^1.1.17",
"@iconify-json/mdi": "^1.1.64",
"@iconify-json/ri": "^1.1.19",
"@iconify-json/ri": "^1.1.20",
"@iconify-json/svg-spinners": "^1.1.2",
"@iconify-json/twemoji": "^1.1.15",
"@nuxt/devtools": "^1.0.8",
"@nuxt/test-utils": "^3.10.0",
"@nuxtjs/color-mode": "^3.3.2",
"@nuxt/devtools": "^1.1.3",
"@nuxt/test-utils": "^3.12.0",
"@nuxtjs/color-mode": "^3.3.3",
"@pinia/nuxt": "^0.5.1",
"@pinia/testing": "^0.1.3",
"@unocss/eslint-config": "^0.58.4",
"@unocss/nuxt": "^0.58.4",
"@vite-pwa/nuxt": "^0.4.0",
"@vue/test-utils": "^2.4.4",
"@vueuse/nuxt": "^10.7.2",
"@yunlefun/vue": "^0.0.9",
"@unocss/eslint-config": "^0.58.6",
"@unocss/nuxt": "^0.58.6",
"@vite-pwa/nuxt": "^0.6.0",
"@vue/test-utils": "^2.4.5",
"@vueuse/nuxt": "^10.9.0",
"@yunlefun/vue": "^0.1.1",
"@zadigetvoltaire/nuxt-gtm": "^0.0.13",
"bumpp": "^9.3.0",
"bumpp": "^9.4.0",
"consola": "^3.2.3",
"dexie": "^3.2.4",
"eslint": "^8.56.0",
"dexie": "^3.2.7",
"eslint": "^8.57.0",
"eslint-plugin-format": "^0.1.0",
"fake-indexeddb": "^5.0.2",
"happy-dom": "^13.3.1",
"happy-dom": "^14.3.1",
"jsdom": "^24.0.0",
"nuxt": "^3.9.3",
"nuxt-vitest": "^0.11.5",
"pinia": "^2.1.7",
"sass": "^1.70.0",
"sass": "^1.72.0",
"serve": "^14.2.1",
"star-markdown-css": "^0.4.2",
"tsx": "^4.7.0",
"typescript": "^5.3.3",
"unocss": "^0.58.4",
"vitest": "^1.2.2",
"vue-tsc": "^1.8.27"
"tsx": "^4.7.1",
"typescript": "^5.4.3",
"unocss": "^0.58.6",
"vitest": "^1.4.0",
"vue-tsc": "^2.0.7"
}
}

1
packages/ai/.env.example Normal file
View File

@@ -0,0 +1 @@
OPENAI_API_KEY=

5
packages/ai/package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"devDependencies": {
"openai": "^4.29.2"
}
}

197
packages/ai/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,197 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
devDependencies:
openai:
specifier: ^4.29.2
version: 4.29.2
packages:
/@types/node-fetch@2.6.11:
resolution: {integrity: sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==}
dependencies:
'@types/node': 18.19.26
form-data: 4.0.0
dev: true
/@types/node@18.19.26:
resolution: {integrity: sha512-+wiMJsIwLOYCvUqSdKTrfkS8mpTp+MPINe6+Np4TAGFWWRWiBQ5kSq9nZGCSPkzx9mvT+uEukzpX4MOSCydcvw==}
dependencies:
undici-types: 5.26.5
dev: true
/abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
dependencies:
event-target-shim: 5.0.1
dev: true
/agentkeepalive@4.5.0:
resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==}
engines: {node: '>= 8.0.0'}
dependencies:
humanize-ms: 1.2.1
dev: true
/asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: true
/base-64@0.1.0:
resolution: {integrity: sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==}
dev: true
/charenc@0.0.2:
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
dev: true
/combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
dependencies:
delayed-stream: 1.0.0
dev: true
/crypt@0.0.2:
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
dev: true
/delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dev: true
/digest-fetch@1.3.0:
resolution: {integrity: sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==}
dependencies:
base-64: 0.1.0
md5: 2.3.0
dev: true
/event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
dev: true
/form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
dev: true
/form-data@4.0.0:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.35
dev: true
/formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
dev: true
/humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
dependencies:
ms: 2.1.3
dev: true
/is-buffer@1.1.6:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
dev: true
/md5@2.3.0:
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
dependencies:
charenc: 0.0.2
crypt: 0.0.2
is-buffer: 1.1.6
dev: true
/mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
dev: true
/mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
dependencies:
mime-db: 1.52.0
dev: true
/ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
dev: true
/node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
dev: true
/node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
dependencies:
whatwg-url: 5.0.0
dev: true
/openai@4.29.2:
resolution: {integrity: sha512-cPkT6zjEcE4qU5OW/SoDDuXEsdOLrXlAORhzmaguj5xZSPlgKvLhi27sFWhLKj07Y6WKNWxcwIbzm512FzTBNQ==}
hasBin: true
dependencies:
'@types/node': 18.19.26
'@types/node-fetch': 2.6.11
abort-controller: 3.0.0
agentkeepalive: 4.5.0
digest-fetch: 1.3.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
web-streams-polyfill: 3.3.3
transitivePeerDependencies:
- encoding
dev: true
/tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: true
/undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
dev: true
/web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
dev: true
/web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
dev: true
/webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
dev: true
/whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
dev: true

53
packages/ai/src/api.ts Normal file
View File

@@ -0,0 +1,53 @@
import consola from 'consola'
import type OpenAI from 'openai'
import { baseChatCompletionCreateParams, baseModel, config, openai } from './config'
// TODO: pass params
export async function getCompletion(msg: string) {
const chatCompletion = await openai.chat.completions.create({
...baseChatCompletionCreateParams,
messages: [{ role: 'user', content: msg }],
model: baseModel,
})
return chatCompletion.choices
}
/**
* 获取 ai 生成的菜谱信息
*/
export async function getAIRecipeInfo(zhFoods: string[]) {
/**
* 限制输入长度
*/
const promptFoods = zhFoods.join('、').slice(0, config.inputMaxLength)
// 尽可能少的 token
const tooltip = `
使用以下材料【${promptFoods}】做一道菜,请为这道菜起个名字,最好带有文化底蕴。
不要使用生僻字和标点符号。
并给出一个有趣的不超过100字的介绍。
格式类型:{
"名称": "",
"介绍": ""
}
直接给出可以被 JSON.parse 解析的字符串,不需要解释内容。`
const messages: OpenAI.ChatCompletionMessageParam[] = [
{
role: 'system',
content: tooltip,
},
]
const chatCompletion = await openai.chat.completions.create({
...baseChatCompletionCreateParams,
messages,
model: baseModel,
// stream: true
})
consola.debug(chatCompletion)
return chatCompletion.choices[0].message
}

26
packages/ai/src/config.ts Normal file
View File

@@ -0,0 +1,26 @@
import 'dotenv/config'
import process from 'node:process'
import OpenAI from 'openai'
const deepseekApiUrl = 'https://api.deepseek.com/v1'
const aiServiceUrl = process.env.AI_SERVICE_URL || deepseekApiUrl
export const config = {
inputMaxLength: 300,
}
export const openai = new OpenAI({
baseURL: aiServiceUrl,
apiKey: process.env.OPENAI_API_KEY, // This is the default and can be omitted
})
export const baseModel = process.env.MODEL_NAME || 'deepseek-chat'
export const baseChatCompletionCreateParams: Partial<OpenAI.ChatCompletionCreateParamsNonStreaming> = {
max_tokens: 100,
// TODO: for use control
// presence_penalty: 0,
// frequency_penalty: 0,
// stream: true
}

3
packages/ai/src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './api'
export * from './config'
export * from './types'

7
packages/ai/src/types.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* AI 食谱
*/
export interface AIRecipeInfo {
名称: string
介绍: string
}

5936
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- packages/*

View File

@@ -0,0 +1,40 @@
import consola from 'consola'
import type { AIRecipeInfo } from '~/packages/ai/src'
import { getAIRecipeInfo } from '~/packages/ai/src'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const zhFoods = body.foods as string[]
consola.debug(zhFoods)
const data = await getAIRecipeInfo(zhFoods)
const { content } = data
let unWrapperContent = content || ''
const startPos = unWrapperContent.indexOf('{')
const endPos = unWrapperContent.lastIndexOf('}')
if (startPos === -1 || endPos === -1) {
// eslint-disable-next-line no-console
console.log(content)
return
}
unWrapperContent = unWrapperContent.slice(startPos, endPos + 1)
unWrapperContent = (unWrapperContent || '{}')?.replace('```json\n', '').replace('```', '')
unWrapperContent = unWrapperContent.endsWith('}') ? unWrapperContent : `${unWrapperContent}}`
let coupletData: AIRecipeInfo | undefined
try {
coupletData = JSON.parse(unWrapperContent) as AIRecipeInfo
}
catch (e) {
// eslint-disable-next-line no-console
console.log(content)
console.error(e)
}
return coupletData
})

View File

@@ -0,0 +1,7 @@
const startAt = Date.now()
let count = 0
export default defineEventHandler(() => ({
pageview: count++,
startAt,
}))

0
utils/api/ai.ts Normal file
View File

View File

@@ -8,6 +8,16 @@ export async function generateRecipeImage(foods: string[]) {
})
}
export async function generateRecipeInfo(foods: string[]) {
console.log(foods)
return $fetch('/api/recipes/text/generate', {
method: 'POST',
body: {
foods,
},
})
}
export async function getRecipeImage(foods: string[]) {
const data = await generateRecipeImage(foods)
return `data:image/png;base64,${data.images[0]}`