ce76980fe5
- 新增 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 全景分析文档
480 lines
17 KiB
TypeScript
480 lines
17 KiB
TypeScript
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<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;
|
||
}
|