diff --git a/app/api/ai/recommend/route.ts b/app/api/ai/recommend/route.ts index c0a70ea..32d96f3 100644 --- a/app/api/ai/recommend/route.ts +++ b/app/api/ai/recommend/route.ts @@ -1,93 +1,42 @@ import { NextResponse } from "next/server" +import { BITES_CATALOG, AccessibilityFilter, BitesRestaurant, filterBitesCatalog } from "@/lib/bitesCatalog" import { chatSpark, SparkMessage } from "@/lib/spark" export const runtime = "nodejs" -// 仅使用 page.tsx 中展示的候选餐厅作为数据来源 -const CATALOG = [ - { - name: "培哥烟囱面包", - address: "安徽省合肥市庐阳区含山路29号105-3室", - city: "合肥", - tags: ["听障友好", "面包", "烘焙"], - description: "提供专业手语服务,图文菜单与电子点餐,视觉化叫号与写字板沟通。", - accessibility: { deafFriendly: true, blindFriendly: false }, - }, - { - name: "木马童话黑暗餐厅", - address: "北京西城区西单北大街109号西西友谊酒店8层", - city: "北京", - tags: ["视障友好", "法餐", "日式料理"], - description: "完全黑暗用餐体验,视障员工专业引导,盲文菜单与语音介绍。", - accessibility: { deafFriendly: false, blindFriendly: true }, - }, - { - name: "星巴克东方文德手语门店(广州)", - address: "广州市越秀区文德北路68号东方文德广场一层", - city: "广州", - tags: ["听障友好", "咖啡"], - description: "手语沟通与无障碍动线设计,伙伴支持可视化提示与社区友好活动。", - accessibility: { deafFriendly: true, blindFriendly: false }, - }, - { - name: "全聚德前门店(北京)", - address: "北京市东城区前门大街 全聚德前门店", - city: "北京", - tags: ["无障碍服务", "盲文菜单", "手语"], - description: "盲文菜单与无障碍用餐区,导盲犬友好;员工接受基础手语培训,提供贴心平等的用餐体验。", - accessibility: { deafFriendly: true, blindFriendly: true }, - }, - { - name: "那伽树无障碍咖啡披萨集合店(北京大栅栏)", - address: "北京前门大栅栏 那伽树咖啡厅", - city: "北京", - tags: ["视障友好", "轮椅友好", "咖啡", "披萨"], - description: "全国首家无障碍咖啡披萨集合店,设置缓坡、低位呼叫按钮、风铃定位与宽双开门等设施,倡导残健共融。", - accessibility: { deafFriendly: false, blindFriendly: true }, - }, - { - name: "无声饭店(云南玉溪)", - address: "云南省玉溪市 无声饭店", - city: "玉溪", - tags: ["听障友好", "家常菜"], - description: "由听障员工共同经营,通过学习手语与贴心服务打破沟通障碍,提供温暖、平等的用餐体验。", - accessibility: { deafFriendly: true, blindFriendly: false }, - }, -] - -function filterCatalog(location: string, accessibility: { deafFriendly?: boolean; blindFriendly?: boolean }) { - const loc = (location || "").trim() - const byCity = (item: any) => (!loc ? true : item.city.includes(loc) || loc.includes(item.city)) - const byAccess = (item: any) => { - const needDeaf = !!accessibility?.deafFriendly - const needBlind = !!accessibility?.blindFriendly - if (needDeaf && !item.accessibility.deafFriendly) return false - if (needBlind && !item.accessibility.blindFriendly) return false - return true - } - return CATALOG.filter((x) => byCity(x) && byAccess(x)) +interface RecommendRequestBody { + location?: string + preferences?: string + accessibility?: AccessibilityFilter +} + +interface ModelRecommendation extends Partial { + name?: string + address?: string } // 严格将模型输出限定到本地候选集,并应用地点/无障碍过滤 function normalize(s: string) { return (s || "").replace(/[()()]/g, "").replace(/\s+/g, "").trim() } -function enforceCatalog(recs: any[], location: string, accessibility: { deafFriendly?: boolean; blindFriendly?: boolean }) { - const candidates = filterCatalog(location, accessibility) - const byNameOrAddr = (r: any, c: any) => { + +function enforceCatalog(recs: ModelRecommendation[], location: string, accessibility: AccessibilityFilter) { + const candidates = filterBitesCatalog(location, accessibility) + const byNameOrAddr = (r: ModelRecommendation, c: BitesRestaurant) => { const rn = normalize(r?.name || "") const cn = normalize(c?.name || "") const ra = normalize(r?.address || "") const ca = normalize(c?.address || "") return (rn && rn === cn) || (ra && ra === ca) || (rn && cn.includes(rn)) || (ra && ca.includes(ra)) } - const matched: any[] = [] - for (const r of (recs || [])) { + + const matched: BitesRestaurant[] = [] + for (const r of recs || []) { const m = candidates.find((c) => byNameOrAddr(r, c)) if (m) matched.push(m) } - const uniq = Array.from(new Map(matched.map((x) => [x.name, x])).values()) - return uniq + + return Array.from(new Map(matched.map((x) => [x.name, x])).values()) } function safeParseJson(input: string): any { @@ -96,6 +45,7 @@ function safeParseJson(input: string): any { .replace(/^```json/gi, "") .replace(/^```/gi, "") .replace(/```$/gi, "") + try { return JSON.parse(cleaned) } catch { @@ -110,51 +60,57 @@ function safeParseJson(input: string): any { } export async function POST(req: Request) { - try { - const body = await req.json() - const location: string = body?.location || "" - const preferences: string = body?.preferences || "" - const accessibility: { deafFriendly?: boolean; blindFriendly?: boolean } = body?.accessibility || {} - - const filtersText = `听障友好: ${accessibility?.deafFriendly ? "是" : "否"}; 视障友好: ${accessibility?.blindFriendly ? "是" : "否"}` - - const system: SparkMessage = { - role: "system", - content: [ - "你是无障碍友好美食推荐助手。", - "你的数据来源仅限于下列候选餐厅(来自页面 Barrier-Free-Bites 的静态内容),不可调用任何联网搜索或外部知识:", - JSON.stringify(CATALOG), - "严格只从上述候选中进行筛选与排序,不要发明新的餐厅。", - "只返回合法JSON字符串,不要任何说明、注释或代码块,不要使用中文标点。", - "字段名与示例完全一致:{ recommendations: [{ name, address, city, tags, description }] },按匹配度高到低排序,最多5条。", - ].join("\n"), - } + let location = "" + let preferences = "" + let accessibility: AccessibilityFilter = {} - const user: SparkMessage = { - role: "user", - content: `地点: ${location}\n偏好: ${preferences}\n无障碍偏好: ${filtersText}`, - } + try { + const body = (await req.json()) as RecommendRequestBody + location = body?.location || "" + preferences = body?.preferences || "" + accessibility = body?.accessibility || {} + } catch { + return NextResponse.json({ recommendations: filterBitesCatalog("", {}).slice(0, 5), source: "fallback_bad_request" }) + } - const text = await chatSpark({ messages: [system, user], temperature: 0.3, maxTokens: 1200 }) + const filtersText = `听障友好: ${accessibility?.deafFriendly ? "是" : "否"}; 视障友好: ${accessibility?.blindFriendly ? "是" : "否"}` - const parsed = safeParseJson(text) || { recommendations: filterCatalog(location, accessibility) } + const system: SparkMessage = { + role: "system", + content: [ + "你是无障碍友好美食推荐助手。", + "你的数据来源仅限于下列候选餐厅(来自页面 Barrier-Free-Bites 的静态内容),不可调用任何联网搜索或外部知识:", + JSON.stringify(BITES_CATALOG), + "严格只从上述候选中进行筛选与排序,不要发明新的餐厅。", + "只返回合法JSON字符串,不要任何说明、注释或代码块,不要使用中文标点。", + "字段名与示例完全一致:{ recommendations: [{ name, address, city, tags, description }] },按匹配度高到低排序,最多5条。", + ].join("\n"), + } - const recommendations = Array.isArray(parsed?.recommendations) ? parsed.recommendations : [] + const user: SparkMessage = { + role: "user", + content: `地点: ${location}\n偏好: ${preferences}\n无障碍偏好: ${filtersText}`, + } - // 将模型输出严格限定到候选集并应用过滤 + try { + const text = await chatSpark({ messages: [system, user], temperature: 0.3, maxTokens: 1200 }) + const parsed = safeParseJson(text) || { recommendations: filterBitesCatalog(location, accessibility) } + const recommendations: ModelRecommendation[] = Array.isArray(parsed?.recommendations) ? parsed.recommendations : [] const strict = enforceCatalog(recommendations, location, accessibility) - // 如果模型返回为空或不匹配候选,提供本地降级 - if (!strict || strict.length === 0) { - const fallback = filterCatalog(location, accessibility) + if (!strict.length) { + const fallback = filterBitesCatalog(location, accessibility) return NextResponse.json({ recommendations: fallback.slice(0, 5), source: "fallback" }) } return NextResponse.json({ recommendations: strict.slice(0, 5), source: "spark" }) } catch (err: any) { console.error("[AI Recommend] Error:", err) - // 当星火调用失败时,基于候选列表做本地降级 - const fallback = filterCatalog("", {}) - return NextResponse.json({ recommendations: fallback.slice(0, 5), source: "fallback_error", error: err?.message || "AI推荐失败" }) + const fallback = filterBitesCatalog(location, accessibility) + return NextResponse.json({ + recommendations: fallback.slice(0, 5), + source: "fallback_error", + error: err?.message || "AI推荐失败", + }) } } diff --git a/components/FoodAIDialog.tsx b/components/FoodAIDialog.tsx index 8713212..fcc413d 100644 --- a/components/FoodAIDialog.tsx +++ b/components/FoodAIDialog.tsx @@ -10,6 +10,7 @@ import { Card, CardContent } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { cn } from "@/lib/utils" import { Loader2, WandSparkles } from "lucide-react" +import { filterBitesCatalog } from "@/lib/bitesCatalog" interface Recommendation { name: string @@ -59,9 +60,21 @@ export default function FoodAIDialog() { const data = await res.json() const recs: Recommendation[] = data?.recommendations || [] - setResults(recs) - } catch (e: any) { - setError(e?.message || "AI推荐失败,请稍后再试") + if (recs.length > 0) { + setResults(recs) + if (data?.source && data.source !== "spark") { + setError("AI服务暂不可用,已为您展示本地推荐") + } + return + } + + const localRecs = filterBitesCatalog(location, {}).slice(0, 5) + setResults(localRecs) + setError("AI服务暂不可用,已为您展示本地推荐") + } catch { + const localRecs = filterBitesCatalog(location, {}).slice(0, 5) + setResults(localRecs) + setError("AI服务暂不可用,已为您展示本地推荐") } finally { setLoading(false) } @@ -130,7 +143,7 @@ export default function FoodAIDialog() { {error && ( -
+
{error}
)} {results.length > 0 && ( diff --git a/lib/bitesCatalog.ts b/lib/bitesCatalog.ts new file mode 100644 index 0000000..59eb8bf --- /dev/null +++ b/lib/bitesCatalog.ts @@ -0,0 +1,81 @@ +export interface AccessibilityFilter { + deafFriendly?: boolean + blindFriendly?: boolean +} + +export interface BitesRestaurant { + name: string + address: string + city: string + tags: string[] + description: string + accessibility: { + deafFriendly: boolean + blindFriendly: boolean + } +} + +// Barrier-Free-Bites 页面展示的静态候选数据 +export const BITES_CATALOG: BitesRestaurant[] = [ + { + name: "培哥烟囱面包", + address: "安徽省合肥市庐阳区含山路29号105-3室", + city: "合肥", + tags: ["听障友好", "面包", "烘焙"], + description: "提供专业手语服务,图文菜单与电子点餐,视觉化叫号与写字板沟通。", + accessibility: { deafFriendly: true, blindFriendly: false }, + }, + { + name: "木马童话黑暗餐厅", + address: "北京西城区西单北大街109号西西友谊酒店8层", + city: "北京", + tags: ["视障友好", "法餐", "日式料理"], + description: "完全黑暗用餐体验,视障员工专业引导,盲文菜单与语音介绍。", + accessibility: { deafFriendly: false, blindFriendly: true }, + }, + { + name: "星巴克东方文德手语门店(广州)", + address: "广州市越秀区文德北路68号东方文德广场一层", + city: "广州", + tags: ["听障友好", "咖啡"], + description: "手语沟通与无障碍动线设计,伙伴支持可视化提示与社区友好活动。", + accessibility: { deafFriendly: true, blindFriendly: false }, + }, + { + name: "全聚德前门店(北京)", + address: "北京市东城区前门大街 全聚德前门店", + city: "北京", + tags: ["无障碍服务", "盲文菜单", "手语"], + description: "盲文菜单与无障碍用餐区,导盲犬友好;员工接受基础手语培训,提供贴心平等的用餐体验。", + accessibility: { deafFriendly: true, blindFriendly: true }, + }, + { + name: "那伽树无障碍咖啡披萨集合店(北京大栅栏)", + address: "北京前门大栅栏 那伽树咖啡厅", + city: "北京", + tags: ["视障友好", "轮椅友好", "咖啡", "披萨"], + description: "全国首家无障碍咖啡披萨集合店,设置缓坡、低位呼叫按钮、风铃定位与宽双开门等设施,倡导残健共融。", + accessibility: { deafFriendly: false, blindFriendly: true }, + }, + { + name: "无声饭店(云南玉溪)", + address: "云南省玉溪市 无声饭店", + city: "玉溪", + tags: ["听障友好", "家常菜"], + description: "由听障员工共同经营,通过学习手语与贴心服务打破沟通障碍,提供温暖、平等的用餐体验。", + accessibility: { deafFriendly: true, blindFriendly: false }, + }, +] + +export function filterBitesCatalog(location: string, accessibility: AccessibilityFilter = {}) { + const loc = (location || "").trim() + const byCity = (item: BitesRestaurant) => (!loc ? true : item.city.includes(loc) || loc.includes(item.city)) + const byAccess = (item: BitesRestaurant) => { + const needDeaf = !!accessibility?.deafFriendly + const needBlind = !!accessibility?.blindFriendly + if (needDeaf && !item.accessibility.deafFriendly) return false + if (needBlind && !item.accessibility.blindFriendly) return false + return true + } + return BITES_CATALOG.filter((x) => byCity(x) && byAccess(x)) +} diff --git a/next.config.ts b/next.config.ts index eaf37eb..d9c0bce 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,9 +1,10 @@ import type { NextConfig } from "next"; -const isDev = process.env.NODE_ENV === 'development'; +const isStaticExport = process.env.STATIC_EXPORT === "true" const nextConfig: NextConfig = { - output: 'export', + // 仅在显式开启时使用静态导出,避免禁用 API Routes(如 /api/ai/recommend) + ...(isStaticExport ? { output: "export" as const } : {}), trailingSlash: true, skipTrailingSlashRedirect: true, images: {