Files
no-whatever/src/lib/ai.ts
T
kurihada 61ef54b2bd feat: 盲盒想法输入增加 AI 灵感推荐
输入框下方展示灵感建议标签,点击即填入,降低创作门槛:
- 房间有 ≥2 条想法时调用 DeepSeek 生成贴合调性的推荐
- 想法不足或 AI 失败时 fallback 到 18 条静态灵感随机选 4 条
- 提交新想法后自动刷新推荐,支持手动换一批
2026-02-27 16:52:59 +08:00

211 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}