import OpenAI from "openai"; import type { ChatCompletionMessageParam, ChatCompletionTool, } from "openai/resources/chat/completions"; import type { IdeaTags, PlanItem } from "@/types"; function getClient() { const apiKey = process.env.DEEPSEEK_API_KEY; if (!apiKey) throw new Error("DEEPSEEK_API_KEY is not configured"); return new OpenAI({ baseURL: "https://api.deepseek.com", apiKey }); } const TAG_SYSTEM_PROMPT = `你是一个周末活动分析助手。用户会输入一条周末活动想法,你需要分析并返回结构化 JSON。 返回字段: - category: 活动品类,必须是以下之一: "dining"(餐饮美食)| "outdoor"(户外活动)| "entertainment"(娱乐休闲,如电影、KTV、密室)| "shopping"(购物逛街)| "sports"(运动健身)| "culture"(文化艺术,如博物馆、展览)| "relaxation"(放松休息,如SPA、咖啡、下午茶)| "experience"(体验活动,如手作、烘焙、插花、DIY)| "nature"(自然户外,如露营、徒步、赶海) - timeSlot: 最适合的时间段,必须是以下之一: "morning"(上午)| "afternoon"(下午)| "evening"(晚上)| "flexible"(任意时间都可以)| "all_day"(需要一整天) - estimatedMinutes: 预估活动时长(整数,单位分钟) - costLevel: 费用等级,必须是以下之一: "free"(免费)| "budget"(人均<50元)| "moderate"(人均50-200元)| "premium"(人均>200元) - intensity: 体力强度,必须是以下之一: "chill"(轻松休闲)| "moderate"(适度活动)| "active"(需要体力) - needsBooking: 是否需要提前预约/购票(布尔值) - searchQuery: 在地图服务上搜索的关键词(品牌名、地名或品类名) - searchType: 搜索策略,必须是以下之一: "brand"(连锁品牌,有多个分店)| "place"(唯一地点,如某个公园)| "category"(模糊品类,搜附近匹配的) 只返回 JSON,不要任何额外文字。`; const SCHEDULE_SYSTEM_PROMPT = `你是一个周末行程规划师。根据用户选定的活动和候选地点坐标,生成最优行程安排。 规划原则: 1. 选择地理位置相近的 POI,最小化总移动距离 2. 严格遵守用餐时间窗口:午餐安排在 11:30-13:00,晚餐安排在 17:30-19:30,不得超出此范围 3. 尊重活动的时间偏好(morning 的活动放 9:00-12:00,afternoon 的活动放 13:00-17:00,evening 的活动放 19:00 以后) 4. 活动之间留出合理的交通时间(15-30分钟) 5. 如果有"category"类型的活动,选择离其他已确定地点最近的候选 6. 高低强度活动交替安排,避免连续高体力活动 7. 费用平衡,避免连续安排 premium 级别活动 8. 需要预约(needsBooking)的活动,在 reason 中提醒用户提前预约 返回 JSON 格式: { "items": [ { "time": "10:00", "activity": "原始活动描述", "poi": "选定的具体 POI 名称", "address": "详细地址", "lat": 31.2, "lng": 121.5, "duration": 120, "reason": "选择这个时间和地点的简短理由", "transitToNext": 20, "transitDescription": "地铁2号线(6站) → 地铁9号线(3站)" } ], "summary": "一句话总结这个行程的亮点" } transitToNext 为从本活动结束到下一活动的预计交通时间(分钟),根据两点地理距离估算;transitDescription 为交通方式描述(如"地铁X号线"、"公交XXX路"、"步行");最后一项不填这两个字段。 按时间顺序排列。只返回 JSON。`; export interface ScheduleContext { ideas: { content: string; category: string; timeSlot: string; estimatedMinutes: number; searchQuery: string; searchType: string; costLevel?: string; intensity?: string; needsBooking?: boolean; }[]; candidates: Record< string, { name: string; address: string; lat: number; lng: number; rating?: number }[] >; userLocation: { lat: number; lng: number }; availableTime: { date: string; startHour: number; endHour: number }; } export async function tagIdea(content: string): Promise { try { const client = getClient(); const response = await client.chat.completions.create({ model: "deepseek-chat", messages: [ { role: "system", content: TAG_SYSTEM_PROMPT }, { role: "user", content }, ], response_format: { type: "json_object" }, max_tokens: 200, temperature: 0.3, }); const text = response.choices[0]?.message?.content; if (!text) return null; const parsed = JSON.parse(text); const validCategories = ["dining", "outdoor", "entertainment", "shopping", "sports", "culture", "relaxation", "experience", "nature"]; const validTimeSlots = ["morning", "afternoon", "evening", "flexible", "all_day"]; const validSearchTypes = ["brand", "place", "category"]; const validCostLevels = ["free", "budget", "moderate", "premium"]; const validIntensities = ["chill", "moderate", "active"]; if ( !validCategories.includes(parsed.category) || !validTimeSlots.includes(parsed.timeSlot) || !validSearchTypes.includes(parsed.searchType) || typeof parsed.estimatedMinutes !== "number" || !validCostLevels.includes(parsed.costLevel) || !validIntensities.includes(parsed.intensity) || typeof parsed.needsBooking !== "boolean" || typeof parsed.searchQuery !== "string" ) { return null; } return { category: parsed.category, timeSlot: parsed.timeSlot, estimatedMinutes: parsed.estimatedMinutes, costLevel: parsed.costLevel, intensity: parsed.intensity, needsBooking: parsed.needsBooking, searchQuery: parsed.searchQuery, searchType: parsed.searchType, }; } catch (e) { console.error("tagIdea failed:", e); return null; } } const SUGGEST_SYSTEM_PROMPT = `你是一个脑洞大开的周末活动灵感助手。根据用户房间里已有的活动想法,推荐 4 个风格相近但不重复的新想法。 要求: - 贴合已有想法的整体调性(如果偏文艺就推文艺的,偏冒险就推冒险的) - 适当加入一些意想不到的创意,激发灵感 - 每条想法 5-15 个字,口语化、有画面感 - 不要与已有想法重复或过于相似 只返回 JSON:{ "suggestions": ["想法1", "想法2", "想法3", "想法4"] }`; export async function suggestIdeas(existingIdeas: string[]): Promise { try { const client = getClient(); const response = await client.chat.completions.create({ model: "deepseek-chat", messages: [ { role: "system", content: SUGGEST_SYSTEM_PROMPT }, { role: "user", content: `已有想法:\n${existingIdeas.map((s, i) => `${i + 1}. ${s}`).join("\n")}` }, ], response_format: { type: "json_object" }, max_tokens: 200, temperature: 0.9, }); const text = response.choices[0]?.message?.content; if (!text) return []; const parsed = JSON.parse(text); if (!Array.isArray(parsed.suggestions)) return []; return parsed.suggestions .filter((s: unknown) => typeof s === "string" && s.length > 0) .slice(0, 4); } catch (e) { console.error("suggestIdeas failed:", e); return []; } } export async function generateSchedule( ctx: ScheduleContext, ): Promise<{ items: PlanItem[]; summary: string } | null> { try { const client = getClient(); const userPrompt = ` 可用时间:${ctx.availableTime.date},${ctx.availableTime.startHour}:00 - ${ctx.availableTime.endHour}:00 用户出发位置:纬度 ${ctx.userLocation.lat},经度 ${ctx.userLocation.lng} 活动列表: ${ctx.ideas.map((idea, i) => `${i + 1}. "${idea.content}"(品类:${idea.category},偏好时间:${idea.timeSlot},预估${idea.estimatedMinutes}分钟${idea.costLevel ? `,费用:${idea.costLevel}` : ""}${idea.intensity ? `,强度:${idea.intensity}` : ""}${idea.needsBooking ? ",需预约" : ""})`).join("\n")} 各活动的候选地点: ${Object.entries(ctx.candidates) .map( ([query, pois]) => `"${query}" 的候选:\n${pois.map((p) => ` - ${p.name} | ${p.address} | 坐标(${p.lat},${p.lng})${p.rating ? ` | 评分${p.rating}` : ""}`).join("\n")}`, ) .join("\n\n")} 请为以上活动生成最优行程安排。`; const response = await client.chat.completions.create({ model: "deepseek-chat", messages: [ { role: "system", content: SCHEDULE_SYSTEM_PROMPT }, { role: "user", content: userPrompt }, ], response_format: { type: "json_object" }, max_tokens: 1500, temperature: 0.5, }); const text = response.choices[0]?.message?.content; if (!text) return null; const parsed = JSON.parse(text); if (!Array.isArray(parsed.items) || parsed.items.length === 0) return null; return { items: parsed.items.map((item: Record) => ({ time: String(item.time ?? ""), activity: String(item.activity ?? ""), poi: String(item.poi ?? ""), address: String(item.address ?? ""), lat: Number(item.lat) || 0, lng: Number(item.lng) || 0, duration: Number(item.duration) || 60, reason: String(item.reason ?? ""), ...(item.transitToNext != null && Number(item.transitToNext) > 0 ? { transitToNext: Math.round(Number(item.transitToNext)), ...(item.transitDescription ? { transitDescription: String(item.transitDescription) } : {}), } : {}), })), summary: String(parsed.summary ?? ""), }; } catch (e) { console.error("generateSchedule failed:", e); return null; } } // --------------------------------------------------------------------------- // refinePlan: adjust an existing plan based on a natural-language instruction // --------------------------------------------------------------------------- const REFINE_PLAN_SYSTEM_PROMPT = `你是一个周末行程调整助手。根据用户指令修改现有行程。规则: 1. 只改用户明确要求的部分,其余原样保留 2. 时间安排合理,活动间留交通时间 3. 若需新增活动,poi/address 可留空字符串,lat/lng 填 0 4. 严格按输入的 JSON 结构输出完整 days 数组`; export async function refinePlan( days: import("@/types").WeekendPlanData[], instruction: string, ): Promise { try { const client = getClient(); const response = await client.chat.completions.create({ model: "deepseek-chat", messages: [ { role: "system", content: REFINE_PLAN_SYSTEM_PROMPT }, { role: "user", content: `当前行程:\n${JSON.stringify(days)}\n\n用户指令:${instruction}`, }, ], response_format: { type: "json_object" }, temperature: 0.5, max_tokens: 2000, }); const text = response.choices[0]?.message?.content; if (!text) return null; const parsed = JSON.parse(text); const result = parsed.days ?? parsed; if (!Array.isArray(result) || result.length === 0) return null; if (!result.every((d: unknown) => { if (typeof d !== "object" || d === null) return false; const day = d as Record; return typeof day.date === "string" && Array.isArray(day.items); })) return null; return result as import("@/types").WeekendPlanData[]; } catch (e) { console.error("refinePlan failed:", e); return null; } } // --------------------------------------------------------------------------- // suggestAlternativeItems: recommend 3 alternatives for a single activity // --------------------------------------------------------------------------- const SUGGEST_ALT_SYSTEM_PROMPT = `你是一个周末活动推荐助手。根据当前活动推荐 3 个类似但不同的替代方案,用于替换行程中的该活动。 每个替代方案需要包含: - activity: 活动描述(5-15字,口语化) - searchQuery: 高德地图搜索关键词 - reason: 推荐理由(一句话) 只返回 JSON:{ "alternatives": [{ "activity", "searchQuery", "reason" }] }`; export async function suggestAlternativeItems( activity: string, time: string, ): Promise | null> { try { const client = getClient(); const response = await client.chat.completions.create({ model: "deepseek-chat", messages: [ { role: "system", content: SUGGEST_ALT_SYSTEM_PROMPT }, { role: "user", content: `当前活动:${activity}${time ? `(时间:${time})` : ""}`, }, ], response_format: { type: "json_object" }, temperature: 0.8, max_tokens: 400, }); const text = response.choices[0]?.message?.content; if (!text) return null; const parsed = JSON.parse(text); if (!Array.isArray(parsed.alternatives)) return null; return parsed.alternatives .filter( (a: unknown) => typeof a === "object" && a !== null && typeof (a as Record).activity === "string" && typeof (a as Record).searchQuery === "string", ) .slice(0, 3) as Array<{ activity: string; searchQuery: string; reason: string }>; } catch (e) { console.error("suggestAlternativeItems failed:", e); return null; } } // --------------------------------------------------------------------------- // Generic tool-calling agent loop // --------------------------------------------------------------------------- export interface AgentTool { name: string; description: string; parameters: Record; // JSON Schema for the tool parameters execute: (args: Record) => Promise; progressBefore?: (args: Record) => string; progressAfter?: (args: Record, result: string) => string; } export interface AgentLoopOptions { systemPrompt: string; userPrompt: string; tools: AgentTool[]; onProgress?: (message: string) => void; maxTurns?: number; } export interface AgentLoopResult { finalArgs: Record; turns: number; } export async function runAgentLoop( options: AgentLoopOptions, ): Promise { const { systemPrompt, userPrompt, tools, onProgress, maxTurns = 15 } = options; const client = getClient(); const openaiTools: ChatCompletionTool[] = tools.map((t) => ({ type: "function" as const, function: { name: t.name, description: t.description, parameters: t.parameters, }, })); const toolMap = new Map(tools.map((t) => [t.name, t])); const messages: ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, ]; for (let turn = 0; turn < maxTurns; turn++) { const response = await client.chat.completions.create({ model: "deepseek-chat", messages, tools: openaiTools, temperature: 0.6, max_tokens: 4096, }); const choice = response.choices[0]; if (!choice) break; const assistantMessage = choice.message; // Push AI thinking text to the user if (assistantMessage.content) { onProgress?.(assistantMessage.content); } // No tool calls — conversation ended if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) { // Add the assistant message and break if there's nothing more to do messages.push({ role: "assistant", content: assistantMessage.content ?? "" }); if (choice.finish_reason === "stop") break; continue; } // Add the assistant message (with tool_calls) to history messages.push(assistantMessage as ChatCompletionMessageParam); // Execute each tool call for (const toolCall of assistantMessage.tool_calls) { if (toolCall.type !== "function") continue; const fnName = toolCall.function.name; const tool = toolMap.get(fnName); if (!tool) { messages.push({ role: "tool", tool_call_id: toolCall.id, content: JSON.stringify({ error: `Unknown tool: ${fnName}` }), }); continue; } let args: Record; try { args = JSON.parse(toolCall.function.arguments); } catch (e) { console.error("runAgentLoop: failed to parse tool arguments:", e); args = {}; } // Progress: before execution if (tool.progressBefore) { onProgress?.(tool.progressBefore(args)); } // Execute const result = await tool.execute(args); // Progress: after execution if (tool.progressAfter) { onProgress?.(tool.progressAfter(args, result)); } // If this is finalize_plan, return immediately if (fnName === "finalize_plan") { return { finalArgs: args, turns: turn + 1 }; } // Feed tool result back to the conversation messages.push({ role: "tool", tool_call_id: toolCall.id, content: result, }); } } // Exhausted turns without finalize return null; }