Files
no-whatever/src/lib/ai.ts
T
kurihada ce76980fe5 refactor(P0): JWT 认证、并发安全、错误日志三项安全加固
- 新增 JWT httpOnly cookie 认证链路 (jose),登录/注册签发 token,
  所有用户和盲盒 API 改为从 cookie 提取 userId,不再信任客户端传值
- 新增 /api/auth/logout 端点清除认证 cookie
- GET /api/user 区分 owner/非 owner,非 owner 不暴露 email
- atomicUpdateRoom 新增 per-room 应用层互斥锁,防止 SQLite 下并发 lost update
- 修复 getRoomData 中 fire-and-forget delete 改为 await
- 37 个静默 catch 块跨 17 个文件添加 console.error 日志
- 新增 REFACTOR_PLAN.md 全景分析文档
2026-03-02 17:24:26 +08:00

480 lines
17 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 {
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:00afternoon 的活动放 13:00-17:00evening 的活动放 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<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", "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<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 (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<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 ?? ""),
...(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<import("@/types").WeekendPlanData[] | null> {
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<string, unknown>;
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<Array<{ activity: string; searchQuery: string; reason: string }> | 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<string, unknown>).activity === "string" &&
typeof (a as Record<string, unknown>).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<string, unknown>; // JSON Schema for the tool parameters
execute: (args: Record<string, unknown>) => Promise<string>;
progressBefore?: (args: Record<string, unknown>) => string;
progressAfter?: (args: Record<string, unknown>, result: string) => string;
}
export interface AgentLoopOptions {
systemPrompt: string;
userPrompt: string;
tools: AgentTool[];
onProgress?: (message: string) => void;
maxTurns?: number;
}
export interface AgentLoopResult {
finalArgs: Record<string, unknown>;
turns: number;
}
export async function runAgentLoop(
options: AgentLoopOptions,
): Promise<AgentLoopResult | null> {
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<string, unknown>;
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;
}