318 lines
11 KiB
JavaScript
318 lines
11 KiB
JavaScript
import { ref, computed } from 'vue'
|
||
import { requestAiStream, parsePartialDietPlainText } from '../utils/aiStreamRequest.js'
|
||
|
||
const DIET_AI_PREFILL_KEY = 'tongji_diet_ai_prefill'
|
||
|
||
const emptyDietAiRecommend = () => ({
|
||
breakfast: '',
|
||
drinks: '',
|
||
lunch: '',
|
||
dinner: '',
|
||
tips: '',
|
||
avoid: [],
|
||
analysis: [],
|
||
exercise: null,
|
||
disclaimer: '',
|
||
rules_summary: '',
|
||
source: '',
|
||
date: ''
|
||
})
|
||
|
||
/** AI 饮食助手(今日推荐 + 能不能吃) */
|
||
export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage }) {
|
||
const dietAiRecommend = ref(emptyDietAiRecommend())
|
||
const dietAiLoading = ref(false)
|
||
const dietAiStreaming = ref(false)
|
||
const dietAiReplacing = ref(false)
|
||
const dietAiStreamField = ref('')
|
||
let dietStreamBuffer = ''
|
||
const dietAiAskText = ref('')
|
||
const dietAiAsking = ref(false)
|
||
const dietAiAskResult = ref({ advice: '', level: '', level_label: '', portion: '', food: '' })
|
||
|
||
const hasDietAiMealContent = computed(() => {
|
||
const r = dietAiRecommend.value
|
||
return !!(r.breakfast || r.drinks || r.lunch || r.dinner || r.tips)
|
||
})
|
||
|
||
function detectDietStreamField(text) {
|
||
const tail = String(text || '').match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
||
if (!tail) return 'breakfast'
|
||
const map = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
|
||
return map[tail[1]] || 'breakfast'
|
||
}
|
||
|
||
function mergePartialDietFromStream() {
|
||
const partial = parsePartialDietPlainText(dietStreamBuffer)
|
||
const next = { ...dietAiRecommend.value }
|
||
for (const key of ['breakfast', 'drinks', 'lunch', 'dinner', 'tips']) {
|
||
if (partial[key] != null) next[key] = partial[key]
|
||
}
|
||
if (Array.isArray(partial.avoid)) next.avoid = partial.avoid
|
||
dietAiRecommend.value = next
|
||
dietAiStreamField.value = detectDietStreamField(dietStreamBuffer)
|
||
}
|
||
|
||
function applyDietRecommendPayload(payload) {
|
||
if (!payload) return
|
||
const prevAnalysis = dietAiRecommend.value.analysis
|
||
const prevExercise = dietAiRecommend.value.exercise
|
||
dietAiRecommend.value = {
|
||
...emptyDietAiRecommend(),
|
||
...payload,
|
||
avoid: Array.isArray(payload.avoid) ? payload.avoid : [],
|
||
analysis: Array.isArray(payload.analysis) && payload.analysis.length
|
||
? payload.analysis
|
||
: (Array.isArray(prevAnalysis) ? prevAnalysis : []),
|
||
exercise: (payload.exercise && typeof payload.exercise === 'object')
|
||
? payload.exercise
|
||
: (prevExercise || null)
|
||
}
|
||
}
|
||
|
||
function resolveDietDonePayload(payload) {
|
||
if (!payload) return payload
|
||
if (payload.source !== 'rule' || !dietStreamBuffer) return payload
|
||
|
||
const streamed = parsePartialDietPlainText(dietStreamBuffer)
|
||
if (!streamed.breakfast) return payload
|
||
|
||
return {
|
||
...emptyDietAiRecommend(),
|
||
...payload,
|
||
breakfast: streamed.breakfast,
|
||
drinks: streamed.drinks || payload.drinks || '',
|
||
lunch: streamed.lunch || payload.lunch || '',
|
||
dinner: streamed.dinner || payload.dinner || '',
|
||
tips: streamed.tips || payload.tips || '',
|
||
avoid: (Array.isArray(streamed.avoid) && streamed.avoid.length) ? streamed.avoid : (payload.avoid || []),
|
||
source: 'ai',
|
||
disclaimer: 'AI 建议供参考,仍请结合医嘱与血糖监测。'
|
||
}
|
||
}
|
||
|
||
function onDietAiStreamEvent(event, payload) {
|
||
if (event === 'analysis') {
|
||
if (Array.isArray(payload?.analysis)) {
|
||
dietAiRecommend.value = { ...dietAiRecommend.value, analysis: payload.analysis }
|
||
}
|
||
} else if (event === 'exercise') {
|
||
if (payload?.exercise && typeof payload.exercise === 'object') {
|
||
dietAiRecommend.value = { ...dietAiRecommend.value, exercise: payload.exercise }
|
||
}
|
||
} else if (event === 'delta') {
|
||
dietAiStreaming.value = true
|
||
if (payload.buffer != null) {
|
||
dietStreamBuffer = payload.buffer
|
||
} else {
|
||
dietStreamBuffer += payload.text || ''
|
||
}
|
||
mergePartialDietFromStream()
|
||
} else if (event === 'done') {
|
||
applyDietRecommendPayload(resolveDietDonePayload(payload))
|
||
dietStreamBuffer = ''
|
||
dietAiStreamField.value = ''
|
||
} else if (event === 'error') {
|
||
showUserToast(formatUserMessage(payload?.message, '饮食建议加载失败'))
|
||
}
|
||
}
|
||
|
||
async function fetchDietAiRecommendFallback(refresh) {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyDietAiRecommend',
|
||
method: 'GET',
|
||
timeout: 35000,
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
refresh: refresh ? 1 : 0,
|
||
...(refresh ? { _t: Date.now() } : {})
|
||
}
|
||
}, false)
|
||
if (res?.code === 1 && res.data) {
|
||
applyDietRecommendPayload(res.data)
|
||
} else if (res?.msg) {
|
||
showUserToast(res.msg)
|
||
}
|
||
}
|
||
|
||
async function fetchDietAiRecommend(refresh = false) {
|
||
if (!diagnosisId.value || dietAiLoading.value) return
|
||
dietAiLoading.value = true
|
||
dietAiStreaming.value = false
|
||
dietAiReplacing.value = !!refresh
|
||
dietStreamBuffer = ''
|
||
dietAiStreamField.value = ''
|
||
if (refresh) {
|
||
applyDietRecommendPayload(emptyDietAiRecommend())
|
||
}
|
||
try {
|
||
await requestAiStream({
|
||
baseUrl: proxy.$url,
|
||
url: '/api/tcm/dailyDietAiRecommendStream',
|
||
method: 'GET',
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
refresh: refresh ? 1 : 0,
|
||
...(refresh ? { _t: Date.now() } : {})
|
||
},
|
||
onEvent: onDietAiStreamEvent,
|
||
fallback: () => fetchDietAiRecommendFallback(refresh)
|
||
})
|
||
} catch (e) {
|
||
try {
|
||
await fetchDietAiRecommendFallback(refresh)
|
||
} catch (e2) {
|
||
showUserToast('饮食建议加载失败')
|
||
}
|
||
} finally {
|
||
dietAiLoading.value = false
|
||
dietAiStreaming.value = false
|
||
dietAiReplacing.value = false
|
||
dietAiStreamField.value = ''
|
||
}
|
||
}
|
||
|
||
function refreshDietAiRecommend() {
|
||
if (dietAiLoading.value) return
|
||
fetchDietAiRecommend(true)
|
||
}
|
||
|
||
async function askDietAiFoodFallback(q) {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyDietAiAsk',
|
||
method: 'POST',
|
||
timeout: 35000,
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
question: q
|
||
}
|
||
}, false)
|
||
if (res?.code === 1 && res.data) {
|
||
dietAiAskResult.value = {
|
||
advice: res.data.advice || '',
|
||
level: res.data.level || '',
|
||
level_label: res.data.level_label || '',
|
||
portion: res.data.portion || '',
|
||
food: res.data.food || q
|
||
}
|
||
} else {
|
||
showUserToast(formatUserMessage(res?.msg, '咨询失败'))
|
||
}
|
||
}
|
||
|
||
async function askDietAiFood() {
|
||
const q = String(dietAiAskText.value || '').trim()
|
||
if (!q) {
|
||
showUserToast('请输入想咨询的食物')
|
||
return
|
||
}
|
||
if (!diagnosisId.value) return
|
||
// 发送后立即清空输入框
|
||
dietAiAskText.value = ''
|
||
dietAiAsking.value = true
|
||
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
|
||
try {
|
||
await requestAiStream({
|
||
baseUrl: proxy.$url,
|
||
url: '/api/tcm/dailyDietAiAskStream',
|
||
method: 'POST',
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
question: q
|
||
},
|
||
onEvent: (event, payload) => {
|
||
if (event === 'start' && !dietAiAskResult.value.advice) {
|
||
dietAiAskResult.value = { ...dietAiAskResult.value, advice: payload.message || '正在想…' }
|
||
} else if (event === 'delta') {
|
||
dietAiAskResult.value = {
|
||
...dietAiAskResult.value,
|
||
advice: payload.advice || dietAiAskResult.value.advice
|
||
}
|
||
} else if (event === 'done') {
|
||
dietAiAskResult.value = {
|
||
advice: payload.advice || '',
|
||
level: payload.level || '',
|
||
level_label: payload.level_label || '',
|
||
portion: payload.portion || '',
|
||
food: payload.food || q
|
||
}
|
||
} else if (event === 'error') {
|
||
showUserToast(formatUserMessage(payload?.message, '咨询失败'))
|
||
}
|
||
},
|
||
fallback: () => askDietAiFoodFallback(q)
|
||
})
|
||
} catch (e) {
|
||
try {
|
||
await askDietAiFoodFallback(q)
|
||
} catch (e2) {
|
||
showUserToast('网络异常,请稍后再试')
|
||
}
|
||
} finally {
|
||
dietAiAsking.value = false
|
||
}
|
||
}
|
||
|
||
function onDietAiAskInput(e) {
|
||
dietAiAskText.value = e?.detail?.value ?? ''
|
||
}
|
||
|
||
function saveDietPrefillToStorage() {
|
||
const r = dietAiRecommend.value
|
||
if (!r.breakfast) return
|
||
try {
|
||
uni.setStorageSync(DIET_AI_PREFILL_KEY, {
|
||
breakfast: r.breakfast,
|
||
drinks: r.drinks || '',
|
||
lunch: r.lunch,
|
||
dinner: r.dinner,
|
||
tips: r.tips || ''
|
||
})
|
||
} catch (e) {}
|
||
}
|
||
|
||
function goMoreDietForm(withPrefill = false) {
|
||
if (withPrefill) saveDietPrefillToStorage()
|
||
uni.navigateTo({ url: '/tongji/pages/more?openDiet=1' })
|
||
}
|
||
|
||
async function applyDietAiToForm() {
|
||
if (!dietAiRecommend.value.breakfast) {
|
||
await fetchDietAiRecommend(false)
|
||
}
|
||
goMoreDietForm(true)
|
||
}
|
||
|
||
function openDietForm() {
|
||
goMoreDietForm(false)
|
||
}
|
||
|
||
return {
|
||
dietAiRecommend,
|
||
dietAiLoading,
|
||
dietAiStreaming,
|
||
dietAiReplacing,
|
||
dietAiStreamField,
|
||
dietAiAskText,
|
||
dietAiAsking,
|
||
dietAiAskResult,
|
||
hasDietAiMealContent,
|
||
fetchDietAiRecommend,
|
||
refreshDietAiRecommend,
|
||
askDietAiFood,
|
||
onDietAiAskInput,
|
||
applyDietAiToForm,
|
||
openDietForm
|
||
}
|
||
}
|
||
|
||
/** more 页打开饮食表单时读取并清除预填 */
|
||
export function consumeDietAiPrefill() {
|
||
try {
|
||
const data = uni.getStorageSync(DIET_AI_PREFILL_KEY)
|
||
uni.removeStorageSync(DIET_AI_PREFILL_KEY)
|
||
if (data && typeof data === 'object' && data.breakfast) return data
|
||
} catch (e) {}
|
||
return null
|
||
}
|