Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 59 additions & 103 deletions app/api/ai/recommend/route.ts
Original file line number Diff line number Diff line change
@@ -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<BitesRestaurant> {
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 {
Expand All @@ -96,6 +45,7 @@ function safeParseJson(input: string): any {
.replace(/^```json/gi, "")
.replace(/^```/gi, "")
.replace(/```$/gi, "")

try {
return JSON.parse(cleaned)
} catch {
Expand All @@ -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推荐失败",
})
}
}
21 changes: 17 additions & 4 deletions components/FoodAIDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -130,7 +143,7 @@ export default function FoodAIDialog() {
</div>

{error && (
<div className="text-sm text-red-600"><SafeTranslation tKey="bites.ai_dialog.errors.generic" fallback="生成推荐时出错,请重试" /></div>
<div className="text-sm text-red-600">{error}</div>
)}

{results.length > 0 && (
Expand Down
81 changes: 81 additions & 0 deletions lib/bitesCatalog.ts
Original file line number Diff line number Diff line change
@@ -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))
}
5 changes: 3 additions & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down
Loading