From 682e22ce1fc6e86b92baf6e35f8b3bf0f234eaa3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:58:55 +0000 Subject: [PATCH 01/13] Initial plan From 5f7dac89d9c75593b3f73b7221692688cd67ef17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:17:12 +0000 Subject: [PATCH 02/13] feat: migrate data loading to GoodAction-data external API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update app/api/data/route.ts to fetch activities from https://goodaction-hub.github.io/GoodAction-data/activities.json instead of reading local YAML files. Transforms external schema (meetup→activity, start_time/end_time→year/date) to internal format. - Update lib/bitesCatalog.ts to fetch restaurants from https://goodaction-hub.github.io/GoodAction-data/restaurants.json with city extraction and accessibility detection from features. Keeps FALLBACK_CATALOG and BITES_CATALOG alias for when API is unreachable. filterBitesCatalog() now accepts catalog as first param. - Update app/api/ai/recommend/route.ts to use async fetchBitesCatalog(). - Update components/FoodAIDialog.tsx to use FALLBACK_CATALOG with the new filterBitesCatalog(catalog, location, filter) signature. Also fix pre-existing ESLint error in SafeTranslation component (use ready flag from useTranslation instead of mounted/useEffect). Co-authored-by: TechQuery <19969570+TechQuery@users.noreply.github.com> --- app/api/ai/recommend/route.ts | 144 +++++++++++++++++--------- app/api/data/route.ts | 90 ++++++++++++---- components/FoodAIDialog.tsx | 190 ++++++++++++++++++++++------------ lib/bitesCatalog.ts | 140 ++++++++++++++++++++----- 4 files changed, 397 insertions(+), 167 deletions(-) diff --git a/app/api/ai/recommend/route.ts b/app/api/ai/recommend/route.ts index 32d96f3..41ec7f2 100644 --- a/app/api/ai/recommend/route.ts +++ b/app/api/ai/recommend/route.ts @@ -1,42 +1,60 @@ -import { NextResponse } from "next/server" -import { BITES_CATALOG, AccessibilityFilter, BitesRestaurant, filterBitesCatalog } from "@/lib/bitesCatalog" -import { chatSpark, SparkMessage } from "@/lib/spark" +import { NextResponse } from "next/server"; +import { + fetchBitesCatalog, + AccessibilityFilter, + BitesRestaurant, + filterBitesCatalog, +} from "@/lib/bitesCatalog"; +import { chatSpark, SparkMessage } from "@/lib/spark"; -export const runtime = "nodejs" +export const runtime = "nodejs"; interface RecommendRequestBody { - location?: string - preferences?: string - accessibility?: AccessibilityFilter + location?: string; + preferences?: string; + accessibility?: AccessibilityFilter; } interface ModelRecommendation extends Partial { - name?: string - address?: string + name?: string; + address?: string; } // 严格将模型输出限定到本地候选集,并应用地点/无障碍过滤 function normalize(s: string) { - return (s || "").replace(/[()()]/g, "").replace(/\s+/g, "").trim() + return (s || "") + .replace(/[()()]/g, "") + .replace(/\s+/g, "") + .trim(); } -function enforceCatalog(recs: ModelRecommendation[], location: string, accessibility: AccessibilityFilter) { - const candidates = filterBitesCatalog(location, accessibility) +function enforceCatalog( + recs: ModelRecommendation[], + catalog: BitesRestaurant[], + location: string, + accessibility: AccessibilityFilter, +) { + const candidates = filterBitesCatalog(catalog, 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: 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: BitesRestaurant[] = []; for (const r of recs || []) { - const m = candidates.find((c) => byNameOrAddr(r, c)) - if (m) matched.push(m) + const m = candidates.find((c) => byNameOrAddr(r, c)); + if (m) matched.push(m); } - return Array.from(new Map(matched.map((x) => [x.name, x])).values()) + return Array.from(new Map(matched.map((x) => [x.name, x])).values()); } function safeParseJson(input: string): any { @@ -44,73 +62,99 @@ function safeParseJson(input: string): any { .trim() .replace(/^```json/gi, "") .replace(/^```/gi, "") - .replace(/```$/gi, "") + .replace(/```$/gi, ""); try { - return JSON.parse(cleaned) + return JSON.parse(cleaned); } catch { - const match = cleaned.match(/\{[\s\S]*\}|\[[\s\S]*\]/) + const match = cleaned.match(/\{[\s\S]*\}|\[[\s\S]*\]/); if (match) { try { - return JSON.parse(match[0]) + return JSON.parse(match[0]); } catch {} } - return null + return null; } } export async function POST(req: Request) { - let location = "" - let preferences = "" - let accessibility: AccessibilityFilter = {} + let location = ""; + let preferences = ""; + let accessibility: AccessibilityFilter = {}; + + const catalog = await fetchBitesCatalog(); try { - const body = (await req.json()) as RecommendRequestBody - location = body?.location || "" - preferences = body?.preferences || "" - accessibility = body?.accessibility || {} + 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" }) + return NextResponse.json({ + recommendations: filterBitesCatalog(catalog, "", {}).slice(0, 5), + source: "fallback_bad_request", + }); } - const filtersText = `听障友好: ${accessibility?.deafFriendly ? "是" : "否"}; 视障友好: ${accessibility?.blindFriendly ? "是" : "否"}` + const filtersText = `听障友好: ${accessibility?.deafFriendly ? "是" : "否"}; 视障友好: ${accessibility?.blindFriendly ? "是" : "否"}`; const system: SparkMessage = { role: "system", content: [ "你是无障碍友好美食推荐助手。", "你的数据来源仅限于下列候选餐厅(来自页面 Barrier-Free-Bites 的静态内容),不可调用任何联网搜索或外部知识:", - JSON.stringify(BITES_CATALOG), + JSON.stringify(catalog), "严格只从上述候选中进行筛选与排序,不要发明新的餐厅。", "只返回合法JSON字符串,不要任何说明、注释或代码块,不要使用中文标点。", "字段名与示例完全一致:{ recommendations: [{ name, address, city, tags, description }] },按匹配度高到低排序,最多5条。", ].join("\n"), - } + }; 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) + const text = await chatSpark({ + messages: [system, user], + temperature: 0.3, + maxTokens: 1200, + }); + const parsed = safeParseJson(text) || { + recommendations: filterBitesCatalog(catalog, location, accessibility), + }; + const recommendations: ModelRecommendation[] = Array.isArray( + parsed?.recommendations, + ) + ? parsed.recommendations + : []; + const strict = enforceCatalog( + recommendations, + catalog, + location, + accessibility, + ); if (!strict.length) { - const fallback = filterBitesCatalog(location, accessibility) - return NextResponse.json({ recommendations: fallback.slice(0, 5), source: "fallback" }) + const fallback = filterBitesCatalog(catalog, location, accessibility); + return NextResponse.json({ + recommendations: fallback.slice(0, 5), + source: "fallback", + }); } - return NextResponse.json({ recommendations: strict.slice(0, 5), source: "spark" }) + return NextResponse.json({ + recommendations: strict.slice(0, 5), + source: "spark", + }); } catch (err: any) { - console.error("[AI Recommend] Error:", err) - const fallback = filterBitesCatalog(location, accessibility) + console.error("[AI Recommend] Error:", err); + const fallback = filterBitesCatalog(catalog, location, accessibility); return NextResponse.json({ recommendations: fallback.slice(0, 5), source: "fallback_error", error: err?.message || "AI推荐失败", - }) + }); } } diff --git a/app/api/data/route.ts b/app/api/data/route.ts index 8412570..deb2676 100644 --- a/app/api/data/route.ts +++ b/app/api/data/route.ts @@ -1,31 +1,79 @@ -import { NextResponse } from 'next/server' -import yaml from 'yaml' -import fs from 'fs' -import path from 'path' -import { DeadlineItem } from '@/lib/data' +import { NextResponse } from "next/server"; +import { DeadlineItem, EventData } from "@/lib/data"; -export const dynamic = 'force-static' +export const dynamic = "force-static"; -let STATIC_DATA: DeadlineItem[] = [] -let INIT_ERROR: unknown = null +const DATA_API_URL = + "https://goodaction-hub.github.io/GoodAction-data/activities.json"; -try { - const conferencesPath = path.join(process.cwd(), 'data', 'conferences.yml') - const competitionsPath = path.join(process.cwd(), 'data', 'competitions.yml') - const activitiesPath = path.join(process.cwd(), 'data', 'activities.yml') +interface ExternalEventData { + id: string; + link: string; + start_time?: string; + end_time?: string; + timeline: { deadline: string; comment: string }[]; + timezone: string; + place: string; +} + +interface ExternalDeadlineItem { + title: string; + description: string; + category: "meetup" | "conference" | "competition"; + tags: string[]; + events: ExternalEventData[]; +} + +function transformEvent(event: ExternalEventData): EventData { + const startTime = event.start_time ?? event.timeline[0]?.deadline ?? ""; + const startDate = startTime ? new Date(startTime.replace(" ", "T")) : null; + const year = startDate ? startDate.getFullYear() : new Date().getFullYear(); - const conferencesData = yaml.parse(fs.readFileSync(conferencesPath, 'utf8')) as DeadlineItem[] - const competitionsData = yaml.parse(fs.readFileSync(competitionsPath, 'utf8')) as DeadlineItem[] - const activitiesData = yaml.parse(fs.readFileSync(activitiesPath, 'utf8')) as DeadlineItem[] + const formatDateToChinese = (d: Date) => + `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`; + let date = startDate ? formatDateToChinese(startDate) : ""; + if (startDate && event.end_time) { + const endDate = new Date(event.end_time.replace(" ", "T")); + if (endDate.getTime() !== startDate.getTime()) { + date = `${date}-${endDate.getMonth() + 1}月${endDate.getDate()}日`; + } + } + + return { + year, + id: event.id, + link: event.link, + timeline: event.timeline, + timezone: event.timezone, + date, + place: event.place, + }; +} - STATIC_DATA = [...conferencesData, ...competitionsData, ...activitiesData] -} catch (err) { - INIT_ERROR = err +function transformItem(item: ExternalDeadlineItem): DeadlineItem { + return { + title: item.title, + description: item.description, + category: item.category === "meetup" ? "activity" : item.category, + tags: item.tags ?? [], + events: item.events.map(transformEvent), + }; } export async function GET() { - if (INIT_ERROR) { - return NextResponse.json({ error: 'Failed to load data' }, { status: 500 }) + try { + const res = await fetch(DATA_API_URL, { cache: "force-cache" }); + if (!res.ok) { + return NextResponse.json( + { error: "Failed to fetch data from external API" }, + { status: 502 }, + ); + } + const externalData = (await res.json()) as ExternalDeadlineItem[]; + const data: DeadlineItem[] = externalData.map(transformItem); + return NextResponse.json(data); + } catch (err) { + console.error("Failed to fetch data from external API:", err); + return NextResponse.json({ error: "Failed to load data" }, { status: 500 }); } - return NextResponse.json(STATIC_DATA) } diff --git a/components/FoodAIDialog.tsx b/components/FoodAIDialog.tsx index fcc413d..f636355 100644 --- a/components/FoodAIDialog.tsx +++ b/components/FoodAIDialog.tsx @@ -1,49 +1,57 @@ -"use client" - -import { useState, useEffect } from "react" -import { useTranslation } from "react-i18next" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger, DialogClose } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -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" +"use client"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogTrigger, + DialogClose, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +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, FALLBACK_CATALOG } from "@/lib/bitesCatalog"; interface Recommendation { - name: string - address?: string - city?: string - tags?: string[] - description?: string + name: string; + address?: string; + city?: string; + tags?: string[]; + description?: string; } // 安全翻译组件,避免水合错误 -function SafeTranslation({ tKey, fallback }: { tKey: string; fallback: string }) { - const [mounted, setMounted] = useState(false) - const { t } = useTranslation('translation') - - useEffect(() => { - setMounted(true) - }, []) - - return <>{mounted ? t(tKey) : fallback} +function SafeTranslation({ + tKey, + fallback, +}: { + tKey: string; + fallback: string; +}) { + const { t, ready } = useTranslation("translation"); + return <>{ready ? t(tKey) : fallback}; } export default function FoodAIDialog() { - const [open, setOpen] = useState(false) - const [location, setLocation] = useState("") - const [preferences, setPreferences] = useState("") - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [results, setResults] = useState([]) + const [open, setOpen] = useState(false); + const [location, setLocation] = useState(""); + const [preferences, setPreferences] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [results, setResults] = useState([]); const onSubmit = async () => { - setError(null) - setLoading(true) - setResults([]) + setError(null); + setLoading(true); + setResults([]); try { const res = await fetch("/api/ai/recommend", { method: "POST", @@ -52,33 +60,41 @@ export default function FoodAIDialog() { location, preferences, }), - }) + }); if (!res.ok) { - throw new Error(`请求失败: ${res.status}`) + throw new Error(`请求失败: ${res.status}`); } - const data = await res.json() - const recs: Recommendation[] = data?.recommendations || [] + const data = await res.json(); + const recs: Recommendation[] = data?.recommendations || []; if (recs.length > 0) { - setResults(recs) + setResults(recs); if (data?.source && data.source !== "spark") { - setError("AI服务暂不可用,已为您展示本地推荐") + setError("AI服务暂不可用,已为您展示本地推荐"); } - return + return; } - const localRecs = filterBitesCatalog(location, {}).slice(0, 5) - setResults(localRecs) - setError("AI服务暂不可用,已为您展示本地推荐") + const localRecs = filterBitesCatalog( + FALLBACK_CATALOG, + location, + {}, + ).slice(0, 5); + setResults(localRecs); + setError("AI服务暂不可用,已为您展示本地推荐"); } catch { - const localRecs = filterBitesCatalog(location, {}).slice(0, 5) - setResults(localRecs) - setError("AI服务暂不可用,已为您展示本地推荐") + const localRecs = filterBitesCatalog( + FALLBACK_CATALOG, + location, + {}, + ).slice(0, 5); + setResults(localRecs); + setError("AI服务暂不可用,已为您展示本地推荐"); } finally { - setLoading(false) + setLoading(false); } - } + }; return (
@@ -91,19 +107,37 @@ export default function FoodAIDialog() { onClick={() => setOpen(true)} > - + - - + + + + + +
- +
- +