61ef54b2bd
输入框下方展示灵感建议标签,点击即填入,降低创作门槛: - 房间有 ≥2 条想法时调用 DeepSeek 生成贴合调性的推荐 - 想法不足或 AI 失败时 fallback 到 18 条静态灵感随机选 4 条 - 提交新想法后自动刷新推荐,支持手动换一批
211 lines
7.4 KiB
TypeScript
211 lines
7.4 KiB
TypeScript
import OpenAI from "openai";
|
||
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、咖啡、下午茶)
|
||
- timeSlot: 最适合的时间段,必须是以下之一:
|
||
"morning"(上午)| "afternoon"(下午)| "evening"(晚上)| "flexible"(任意时间都可以)| "all_day"(需要一整天)
|
||
- estimatedMinutes: 预估活动时长(整数,单位分钟)
|
||
- outdoor: 是否户外活动(布尔值)
|
||
- searchQuery: 在地图服务上搜索的关键词(品牌名、地名或品类名)
|
||
- searchType: 搜索策略,必须是以下之一:
|
||
"brand"(连锁品牌,有多个分店)| "place"(唯一地点,如某个公园)| "category"(模糊品类,搜附近匹配的)
|
||
|
||
只返回 JSON,不要任何额外文字。`;
|
||
|
||
const SCHEDULE_SYSTEM_PROMPT = `你是一个周末行程规划师。根据用户选定的活动和候选地点坐标,生成最优行程安排。
|
||
|
||
规划原则:
|
||
1. 选择地理位置相近的 POI,最小化总移动距离
|
||
2. 尊重活动的时间偏好(公园上午、正餐在饭点、电影灵活)
|
||
3. 活动之间留出合理的交通时间(15-30分钟)
|
||
4. 如果有"category"类型的活动,选择离其他已确定地点最近的候选
|
||
|
||
返回 JSON 格式:
|
||
{
|
||
"items": [
|
||
{
|
||
"time": "10:00",
|
||
"activity": "原始活动描述",
|
||
"poi": "选定的具体 POI 名称",
|
||
"address": "详细地址",
|
||
"lat": 31.2,
|
||
"lng": 121.5,
|
||
"duration": 120,
|
||
"reason": "选择这个时间和地点的简短理由"
|
||
}
|
||
],
|
||
"summary": "一句话总结这个行程的亮点"
|
||
}
|
||
|
||
按时间顺序排列。只返回 JSON。`;
|
||
|
||
export interface ScheduleContext {
|
||
ideas: {
|
||
content: string;
|
||
category: string;
|
||
timeSlot: string;
|
||
estimatedMinutes: number;
|
||
searchQuery: string;
|
||
searchType: string;
|
||
}[];
|
||
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<IdeaTags | null> {
|
||
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"];
|
||
const validTimeSlots = ["morning", "afternoon", "evening", "flexible", "all_day"];
|
||
const validSearchTypes = ["brand", "place", "category"];
|
||
|
||
if (
|
||
!validCategories.includes(parsed.category) ||
|
||
!validTimeSlots.includes(parsed.timeSlot) ||
|
||
!validSearchTypes.includes(parsed.searchType) ||
|
||
typeof parsed.estimatedMinutes !== "number" ||
|
||
typeof parsed.outdoor !== "boolean" ||
|
||
typeof parsed.searchQuery !== "string"
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
category: parsed.category,
|
||
timeSlot: parsed.timeSlot,
|
||
estimatedMinutes: parsed.estimatedMinutes,
|
||
outdoor: parsed.outdoor,
|
||
searchQuery: parsed.searchQuery,
|
||
searchType: parsed.searchType,
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
const SUGGEST_SYSTEM_PROMPT = `你是一个脑洞大开的周末活动灵感助手。根据用户房间里已有的活动想法,推荐 4 个风格相近但不重复的新想法。
|
||
|
||
要求:
|
||
- 贴合已有想法的整体调性(如果偏文艺就推文艺的,偏冒险就推冒险的)
|
||
- 适当加入一些意想不到的创意,激发灵感
|
||
- 每条想法 5-15 个字,口语化、有画面感
|
||
- 不要与已有想法重复或过于相似
|
||
|
||
只返回 JSON:{ "suggestions": ["想法1", "想法2", "想法3", "想法4"] }`;
|
||
|
||
export async function suggestIdeas(existingIdeas: string[]): Promise<string[]> {
|
||
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 {
|
||
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}分钟)`).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<string, unknown>) => ({
|
||
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 ?? ""),
|
||
})),
|
||
summary: String(parsed.summary ?? ""),
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|