|
|
|
@ -42,6 +42,53 @@ JSON 格式如下:
|
|
|
|
3. 至少 2 个方案,最多 3 个
|
|
|
|
3. 至少 2 个方案,最多 3 个
|
|
|
|
4. 直接输出 JSON,不要其他内容`
|
|
|
|
4. 直接输出 JSON,不要其他内容`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const CHAT_GATHER_PROMPT = `你是一个专业、贴心的旅行规划助手。你正在通过多轮对话收集用户的旅行需求。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 你的角色
|
|
|
|
|
|
|
|
用自然、友好的语气和用户聊天,逐步了解他们的旅行需求。一次只问 1-2 个问题,不要一次性问完所有问题。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 需要收集的信息(共 7 项,全部收集完毕后才能 ready)
|
|
|
|
|
|
|
|
1. **出行时间** — 几月出发?玩几天?(季节很重要,影响推荐)
|
|
|
|
|
|
|
|
2. **出发地与目的地** — 从哪里出发?去哪里?(城市、省份、区域)
|
|
|
|
|
|
|
|
3. **出行方式** — 自驾 / 公共交通 / 包车 / 跟团?
|
|
|
|
|
|
|
|
4. **人员组成** — 一个人、情侣、家庭(带老人/小孩)?
|
|
|
|
|
|
|
|
5. **偏好** — 喜欢自然风光、文化古迹、美食、购物、休闲度假?
|
|
|
|
|
|
|
|
6. **意向地点** — 有没有特别想去的地方或景点?(可选,没有就说没有)
|
|
|
|
|
|
|
|
7. **禁忌** — 有没有什么不想要的?比如不去某类景点、不吃某类食物、不爬山等(可选,没有就说没有)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 对话规则
|
|
|
|
|
|
|
|
- 如果信息不足(7 项未收集全),继续友好地提问,输出 status: "needs_more_info"
|
|
|
|
|
|
|
|
- 一次只追问 1-2 个最紧迫的信息
|
|
|
|
|
|
|
|
- 对可选项目(意向地点、禁忌),如果用户说"没有"或"随便"就算收集完成
|
|
|
|
|
|
|
|
- 当 7 项全部收集完毕后,输出 status: "ready"
|
|
|
|
|
|
|
|
- 在 ready 状态下,你必须用 summary 字段整理 **全部 7 项** 信息并请用户确认
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 输出格式
|
|
|
|
|
|
|
|
你必须直接输出 JSON,不要包含任何 markdown 代码块标记,不要在 JSON 前后添加任何额外文字。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
当信息不足时:
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"status": "needs_more_info",
|
|
|
|
|
|
|
|
"reply": "你准备什么时候出发呢?不同的季节适合不同的玩法~",
|
|
|
|
|
|
|
|
"missing": ["出行时间"]
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
当信息足够时:
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"status": "ready",
|
|
|
|
|
|
|
|
"reply": "太好了,我整理了一下你的需求,你看对不对?",
|
|
|
|
|
|
|
|
"summary": {
|
|
|
|
|
|
|
|
"time": "9月,5天",
|
|
|
|
|
|
|
|
"origin": "上海",
|
|
|
|
|
|
|
|
"destination": "云南",
|
|
|
|
|
|
|
|
"transport": "自驾",
|
|
|
|
|
|
|
|
"travelers": "带老人",
|
|
|
|
|
|
|
|
"preferences": ["自然风光", "休闲"],
|
|
|
|
|
|
|
|
"intendedSpots": "大理、丽江古城",
|
|
|
|
|
|
|
|
"taboos": "不爬山、不吃辣"
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
|
|
const QUICK_PLAN_PROMPT = `你是一个专业的旅行规划助手。根据用户指定的目的地和天数,快速生成 3 个不同的旅行方案。
|
|
|
|
const QUICK_PLAN_PROMPT = `你是一个专业的旅行规划助手。根据用户指定的目的地和天数,快速生成 3 个不同的旅行方案。
|
|
|
|
|
|
|
|
|
|
|
|
## 重要:输出格式
|
|
|
|
## 重要:输出格式
|
|
|
|
@ -69,39 +116,109 @@ JSON 格式如下:
|
|
|
|
"tips": "旅行贴士,如最佳季节、注意事项、必备物品等",
|
|
|
|
"tips": "旅行贴士,如最佳季节、注意事项、必备物品等",
|
|
|
|
"points": [
|
|
|
|
"points": [
|
|
|
|
{
|
|
|
|
{
|
|
|
|
"name": "地点名称",
|
|
|
|
"name": "昆明",
|
|
|
|
"lat": 25.04,
|
|
|
|
"lat": 25.04,
|
|
|
|
"lng": 102.71,
|
|
|
|
"lng": 102.71,
|
|
|
|
"day": "Day 1",
|
|
|
|
"day": "Day 1",
|
|
|
|
"badge": "START",
|
|
|
|
"badge": "START",
|
|
|
|
"icon": "🏙️",
|
|
|
|
"icon": "🏙️",
|
|
|
|
"desc": "该地点简短描述",
|
|
|
|
"desc": "春城昆明,云南省会",
|
|
|
|
"km": "0km",
|
|
|
|
"km": "0km",
|
|
|
|
"driveTime": "—",
|
|
|
|
"driveTime": "—",
|
|
|
|
"schedule": [
|
|
|
|
"schedule": [
|
|
|
|
{
|
|
|
|
{
|
|
|
|
"time": "上午",
|
|
|
|
"time": "上午",
|
|
|
|
"title": "标题",
|
|
|
|
"title": "抵达昆明",
|
|
|
|
"content": "行程安排简述",
|
|
|
|
"content": "抵达昆明,入住酒店",
|
|
|
|
"desc": "详细描述"
|
|
|
|
"desc": "建议选择翠湖附近的酒店"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
],
|
|
|
|
],
|
|
|
|
"foods": [
|
|
|
|
"foods": [
|
|
|
|
{
|
|
|
|
{
|
|
|
|
"name": "美食名称",
|
|
|
|
"name": "过桥米线",
|
|
|
|
"icon": "🍜",
|
|
|
|
"icon": "🍜",
|
|
|
|
"desc": "美食简介"
|
|
|
|
"desc": "云南经典美食"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
],
|
|
|
|
],
|
|
|
|
"waypoints": [
|
|
|
|
"waypoints": [
|
|
|
|
{
|
|
|
|
{
|
|
|
|
"name": "途径地点名称",
|
|
|
|
"name": "滇池",
|
|
|
|
"icon": "📍",
|
|
|
|
"icon": "📍",
|
|
|
|
"desc": "简短介绍"
|
|
|
|
"desc": "云南最大淡水湖"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
],
|
|
|
|
],
|
|
|
|
"hotel": "推荐住宿",
|
|
|
|
"hotel": "昆明翠湖酒店",
|
|
|
|
"tips": "注意事项"
|
|
|
|
"tips": "注意高原防晒"
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"name": "大理",
|
|
|
|
|
|
|
|
"lat": 25.59,
|
|
|
|
|
|
|
|
"lng": 100.23,
|
|
|
|
|
|
|
|
"day": "Day 2",
|
|
|
|
|
|
|
|
"badge": "D1",
|
|
|
|
|
|
|
|
"icon": "🏔️",
|
|
|
|
|
|
|
|
"desc": "风花雪月的大理古城",
|
|
|
|
|
|
|
|
"km": "320km",
|
|
|
|
|
|
|
|
"driveTime": "4h",
|
|
|
|
|
|
|
|
"schedule": [
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"time": "上午",
|
|
|
|
|
|
|
|
"title": "游览洱海",
|
|
|
|
|
|
|
|
"content": "环洱海骑行,感受苍山洱海美景",
|
|
|
|
|
|
|
|
"desc": "建议租电动车,全程约50km"
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
"foods": [
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"name": "大理酸辣鱼",
|
|
|
|
|
|
|
|
"icon": "🐟",
|
|
|
|
|
|
|
|
"desc": "洱海鲜鱼制作"
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
"waypoints": [
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"name": "崇圣寺三塔",
|
|
|
|
|
|
|
|
"icon": "📍",
|
|
|
|
|
|
|
|
"desc": "大理标志性建筑"
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
"hotel": "大理古城客栈",
|
|
|
|
|
|
|
|
"tips": "古城内步行最佳"
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"name": "丽江",
|
|
|
|
|
|
|
|
"lat": 26.87,
|
|
|
|
|
|
|
|
"lng": 100.23,
|
|
|
|
|
|
|
|
"day": "Day 3",
|
|
|
|
|
|
|
|
"badge": "END",
|
|
|
|
|
|
|
|
"icon": "🏯",
|
|
|
|
|
|
|
|
"desc": "世界文化遗产丽江古城",
|
|
|
|
|
|
|
|
"km": "180km",
|
|
|
|
|
|
|
|
"driveTime": "2.5h",
|
|
|
|
|
|
|
|
"schedule": [
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"time": "下午",
|
|
|
|
|
|
|
|
"title": "漫步古城",
|
|
|
|
|
|
|
|
"content": "游览丽江古城,感受纳西文化",
|
|
|
|
|
|
|
|
"desc": "四方街、木府、大水车"
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
"foods": [
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"name": "腊排骨火锅",
|
|
|
|
|
|
|
|
"icon": "🍲",
|
|
|
|
|
|
|
|
"desc": "丽江特色美食"
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
"waypoints": [
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"name": "束河古镇",
|
|
|
|
|
|
|
|
"icon": "📍",
|
|
|
|
|
|
|
|
"desc": "比大研古城更静谧的古镇"
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
"hotel": "丽江特色民宿",
|
|
|
|
|
|
|
|
"tips": "提前预订古城内住宿"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
]
|
|
|
|
]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -118,7 +235,8 @@ JSON 格式如下:
|
|
|
|
7. foods 中每个对象必须包含 name、icon、desc
|
|
|
|
7. foods 中每个对象必须包含 name、icon、desc
|
|
|
|
8. waypoints 中每个对象必须包含 name、icon、desc
|
|
|
|
8. waypoints 中每个对象必须包含 name、icon、desc
|
|
|
|
9. daysDetail 数组长度必须等于 days,每天一个对象
|
|
|
|
9. daysDetail 数组长度必须等于 days,每天一个对象
|
|
|
|
10. 直接输出 JSON,不要其他内容`
|
|
|
|
10. points 数组必须包含行程中所有途经站点(每个 daysDetail 中的 location 至少对应一个 point),总点数至少 3 个(START、END 和至少一个中间站),每个 point 的 badge 依次为 START、D1、D2...、END
|
|
|
|
|
|
|
|
11. 直接输出 JSON,不要其他内容`
|
|
|
|
|
|
|
|
|
|
|
|
const CUSTOM_PLAN_PROMPT = `你是一个专业的旅行规划助手。用户已经输入了他/她的行程安排,你需要:
|
|
|
|
const CUSTOM_PLAN_PROMPT = `你是一个专业的旅行规划助手。用户已经输入了他/她的行程安排,你需要:
|
|
|
|
1. 解析用户的行程安排
|
|
|
|
1. 解析用户的行程安排
|
|
|
|
@ -197,7 +315,101 @@ JSON 格式如下:
|
|
|
|
3. 即使行程合理也要生成两个方案(优化版和原版)
|
|
|
|
3. 即使行程合理也要生成两个方案(优化版和原版)
|
|
|
|
4. 直接输出 JSON,不要其他内容`
|
|
|
|
4. 直接输出 JSON,不要其他内容`
|
|
|
|
|
|
|
|
|
|
|
|
export async function chatWithAI(userMessage, onThinking = null) {
|
|
|
|
/**
|
|
|
|
|
|
|
|
* 多轮对话 — 收集需求阶段
|
|
|
|
|
|
|
|
* @param {Array} history - 对话历史 [{role: 'user'|'assistant', content: string}]
|
|
|
|
|
|
|
|
* @param {Function} onThinking - 流式回调
|
|
|
|
|
|
|
|
* @param {Object} context - { userId, guestId, eventType }
|
|
|
|
|
|
|
|
* @returns {Promise<{status: string, reply: string, summary?: object, missing?: string[]}>}
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
export async function chatGather(history, onThinking = null, context = {}) {
|
|
|
|
|
|
|
|
const apiUrl = import.meta.env.DEV ? 'http://localhost:3001/api/chat' : '/api/chat'
|
|
|
|
|
|
|
|
const messages = [
|
|
|
|
|
|
|
|
{ role: 'system', content: CHAT_GATHER_PROMPT },
|
|
|
|
|
|
|
|
...history
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const response = await fetch(apiUrl, {
|
|
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
|
|
messages,
|
|
|
|
|
|
|
|
stream: true,
|
|
|
|
|
|
|
|
user_id: context.userId || null,
|
|
|
|
|
|
|
|
guest_id: context.guestId || null,
|
|
|
|
|
|
|
|
event_type: context.eventType || 'gather'
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
|
|
const error = await response.json().catch(() => ({}))
|
|
|
|
|
|
|
|
throw new Error(error.error?.message || `API 请求失败: ${response.status}`)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const reader = response.body.getReader()
|
|
|
|
|
|
|
|
const decoder = new TextDecoder()
|
|
|
|
|
|
|
|
let fullContent = ''
|
|
|
|
|
|
|
|
let reasoningContent = ''
|
|
|
|
|
|
|
|
let buffer = ''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
|
|
|
const { done, value } = await reader.read()
|
|
|
|
|
|
|
|
if (done) break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
buffer += decoder.decode(value, { stream: true })
|
|
|
|
|
|
|
|
const lines = buffer.split('\n')
|
|
|
|
|
|
|
|
buffer = lines.pop() || ''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
|
|
|
const trimmed = line.trim()
|
|
|
|
|
|
|
|
if (!trimmed || !trimmed.startsWith('data:')) continue
|
|
|
|
|
|
|
|
const data = trimmed.slice(5).trim()
|
|
|
|
|
|
|
|
if (data === '[DONE]') break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const parsed = JSON.parse(data)
|
|
|
|
|
|
|
|
const delta = parsed.choices?.[0]?.delta || {}
|
|
|
|
|
|
|
|
if (delta.reasoning_content) {
|
|
|
|
|
|
|
|
reasoningContent += delta.reasoning_content
|
|
|
|
|
|
|
|
if (onThinking) onThinking(reasoningContent)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const chunk = delta.content || ''
|
|
|
|
|
|
|
|
if (chunk) {
|
|
|
|
|
|
|
|
fullContent += chunk
|
|
|
|
|
|
|
|
if (onThinking) onThinking(fullContent || reasoningContent)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
// Skip malformed JSON chunks
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 解析 AI 返回的 JSON
|
|
|
|
|
|
|
|
let cleaned = (fullContent || reasoningContent).trim()
|
|
|
|
|
|
|
|
const codeBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/)
|
|
|
|
|
|
|
|
if (codeBlockMatch) cleaned = codeBlockMatch[1].trim()
|
|
|
|
|
|
|
|
cleaned = cleaned.replace(/<thinking>[\s\S]*?<\/thinking>/g, '').trim()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const parsed = JSON.parse(cleaned)
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
status: parsed.status || 'needs_more_info',
|
|
|
|
|
|
|
|
reply: parsed.reply || '',
|
|
|
|
|
|
|
|
summary: parsed.summary || null,
|
|
|
|
|
|
|
|
missing: parsed.missing || []
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
// 如果解析失败,将原始内容作为回复
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
status: 'needs_more_info',
|
|
|
|
|
|
|
|
reply: cleaned,
|
|
|
|
|
|
|
|
summary: null,
|
|
|
|
|
|
|
|
missing: []
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function chatWithAI(userMessage, onThinking = null, context = {}) {
|
|
|
|
// Use Express API server directly (port 3001) instead of Vite proxy
|
|
|
|
// Use Express API server directly (port 3001) instead of Vite proxy
|
|
|
|
const apiUrl = import.meta.env.DEV ? 'http://localhost:3001/api/chat' : '/api/chat'
|
|
|
|
const apiUrl = import.meta.env.DEV ? 'http://localhost:3001/api/chat' : '/api/chat'
|
|
|
|
const response = await fetch(apiUrl, {
|
|
|
|
const response = await fetch(apiUrl, {
|
|
|
|
@ -210,7 +422,10 @@ export async function chatWithAI(userMessage, onThinking = null) {
|
|
|
|
{ role: 'system', content: SYSTEM_PROMPT },
|
|
|
|
{ role: 'system', content: SYSTEM_PROMPT },
|
|
|
|
{ role: 'user', content: userMessage }
|
|
|
|
{ role: 'user', content: userMessage }
|
|
|
|
],
|
|
|
|
],
|
|
|
|
stream: true
|
|
|
|
stream: true,
|
|
|
|
|
|
|
|
user_id: context.userId || null,
|
|
|
|
|
|
|
|
guest_id: context.guestId || null,
|
|
|
|
|
|
|
|
event_type: context.eventType || 'chat'
|
|
|
|
})
|
|
|
|
})
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
@ -325,13 +540,19 @@ function parseAIResponse(content) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Shared streaming function
|
|
|
|
// Shared streaming function
|
|
|
|
async function streamAIRequest(messages, onThinking = null, signal = null) {
|
|
|
|
async function streamAIRequest(messages, onThinking = null, signal = null, context = {}) {
|
|
|
|
const apiUrl = import.meta.env.DEV ? 'http://localhost:3001/api/chat' : '/api/chat'
|
|
|
|
const apiUrl = import.meta.env.DEV ? 'http://localhost:3001/api/chat' : '/api/chat'
|
|
|
|
console.log('[streamAI] Fetching:', apiUrl)
|
|
|
|
console.log('[streamAI] Fetching:', apiUrl)
|
|
|
|
const response = await fetch(apiUrl, {
|
|
|
|
const response = await fetch(apiUrl, {
|
|
|
|
method: 'POST',
|
|
|
|
method: 'POST',
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
body: JSON.stringify({ messages, stream: true }),
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
|
|
messages,
|
|
|
|
|
|
|
|
stream: true,
|
|
|
|
|
|
|
|
user_id: context.userId || null,
|
|
|
|
|
|
|
|
guest_id: context.guestId || null,
|
|
|
|
|
|
|
|
event_type: context.eventType || 'chat'
|
|
|
|
|
|
|
|
}),
|
|
|
|
signal
|
|
|
|
signal
|
|
|
|
})
|
|
|
|
})
|
|
|
|
console.log('[streamAI] Response status:', response.status)
|
|
|
|
console.log('[streamAI] Response status:', response.status)
|
|
|
|
@ -440,7 +661,7 @@ function parseJSONFromContent(content) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function quickPlan(userInput, onThinking = null, signal = null) {
|
|
|
|
export async function quickPlan(userInput, onThinking = null, signal = null, context = {}) {
|
|
|
|
const userMessage = `目的地:${userInput.destination}
|
|
|
|
const userMessage = `目的地:${userInput.destination}
|
|
|
|
出行天数:${userInput.days}天
|
|
|
|
出行天数:${userInput.days}天
|
|
|
|
出发日期:${userInput.startDate}
|
|
|
|
出发日期:${userInput.startDate}
|
|
|
|
@ -458,7 +679,7 @@ export async function quickPlan(userInput, onThinking = null, signal = null) {
|
|
|
|
const content = await streamAIRequest([
|
|
|
|
const content = await streamAIRequest([
|
|
|
|
{ role: 'system', content: QUICK_PLAN_PROMPT },
|
|
|
|
{ role: 'system', content: QUICK_PLAN_PROMPT },
|
|
|
|
{ role: 'user', content: userMessage }
|
|
|
|
{ role: 'user', content: userMessage }
|
|
|
|
], onThinking, signal)
|
|
|
|
], onThinking, signal, { ...context, eventType: context.eventType || 'quick_plan' })
|
|
|
|
|
|
|
|
|
|
|
|
const parsed = parseJSONFromContent(content)
|
|
|
|
const parsed = parseJSONFromContent(content)
|
|
|
|
|
|
|
|
|
|
|
|
@ -466,11 +687,18 @@ export async function quickPlan(userInput, onThinking = null, signal = null) {
|
|
|
|
throw new Error('AI 返回结果缺少 schemes')
|
|
|
|
throw new Error('AI 返回结果缺少 schemes')
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证每个 scheme 都有 points
|
|
|
|
// 验证每个 scheme 都有足够的 points
|
|
|
|
for (let i = 0; i < parsed.schemes.length; i++) {
|
|
|
|
for (let i = 0; i < parsed.schemes.length; i++) {
|
|
|
|
const scheme = parsed.schemes[i]
|
|
|
|
const scheme = parsed.schemes[i]
|
|
|
|
if (!scheme.points || !Array.isArray(scheme.points) || scheme.points.length === 0) {
|
|
|
|
if (!scheme.points || !Array.isArray(scheme.points) || scheme.points.length < 2) {
|
|
|
|
throw new Error(`方案 ${i + 1} (${scheme.name || '未知'}) 缺少 points 数据`)
|
|
|
|
throw new Error(`方案 ${i + 1} (${scheme.name || '未知'}) points 数据不足,需要至少 2 个站点,当前 ${scheme.points?.length || 0} 个`)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// 验证 badge 顺序:第一个必须是 START,最后一个必须是 END
|
|
|
|
|
|
|
|
if (scheme.points[0].badge !== 'START') {
|
|
|
|
|
|
|
|
throw new Error(`方案 ${i + 1} (${scheme.name || '未知'}) 第一个 point 缺少 START badge`)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (scheme.points[scheme.points.length - 1].badge !== 'END') {
|
|
|
|
|
|
|
|
throw new Error(`方案 ${i + 1} (${scheme.name || '未知'}) 最后一个 point 缺少 END badge`)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
console.log(`[quickPlan] 方案 ${i + 1} 验证通过: ${scheme.name}, points: ${scheme.points.length}`)
|
|
|
|
console.log(`[quickPlan] 方案 ${i + 1} 验证通过: ${scheme.name}, points: ${scheme.points.length}`)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -502,7 +730,7 @@ export async function quickPlan(userInput, onThinking = null, signal = null) {
|
|
|
|
throw new Error(`AI 规划失败(已重试 ${MAX_RETRIES} 次):${lastError?.message || '未知错误'}`)
|
|
|
|
throw new Error(`AI 规划失败(已重试 ${MAX_RETRIES} 次):${lastError?.message || '未知错误'}`)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function customPlan(userInput, onThinking = null) {
|
|
|
|
export async function customPlan(userInput, onThinking = null, context = {}) {
|
|
|
|
const userMessage = `我的行程安排:
|
|
|
|
const userMessage = `我的行程安排:
|
|
|
|
${userInput.itinerary}
|
|
|
|
${userInput.itinerary}
|
|
|
|
|
|
|
|
|
|
|
|
@ -520,7 +748,7 @@ ${userInput.itinerary}
|
|
|
|
const content = await streamAIRequest([
|
|
|
|
const content = await streamAIRequest([
|
|
|
|
{ role: 'system', content: CUSTOM_PLAN_PROMPT },
|
|
|
|
{ role: 'system', content: CUSTOM_PLAN_PROMPT },
|
|
|
|
{ role: 'user', content: userMessage }
|
|
|
|
{ role: 'user', content: userMessage }
|
|
|
|
], onThinking)
|
|
|
|
], onThinking, null, { ...context, eventType: context.eventType || 'custom_plan' })
|
|
|
|
|
|
|
|
|
|
|
|
const parsed = parseJSONFromContent(content)
|
|
|
|
const parsed = parseJSONFromContent(content)
|
|
|
|
|
|
|
|
|
|
|
|
@ -541,3 +769,267 @@ ${userInput.itinerary}
|
|
|
|
|
|
|
|
|
|
|
|
throw new Error(`AI 评估失败(已重试 ${MAX_RETRIES} 次):${lastError?.message || '未知错误'}`)
|
|
|
|
throw new Error(`AI 评估失败(已重试 ${MAX_RETRIES} 次):${lastError?.message || '未知错误'}`)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const REPLACE_PROMPT = `你是一个专业的旅行规划助手。用户当前行程中有一个站点需要替换为其他同类型目的地。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 任务
|
|
|
|
|
|
|
|
根据当前站点、上一个站点和下一个站点的信息,推荐 3-5 个适合替换的替代目的地。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 要求
|
|
|
|
|
|
|
|
1. 替代目的地应该与当前站点类型相似(自然风光、古城、美食等)
|
|
|
|
|
|
|
|
2. 路线要顺路,不能偏离主路线太远
|
|
|
|
|
|
|
|
3. lat/lng 必须是真实中国地理坐标
|
|
|
|
|
|
|
|
4. 每个替代品必须包含 icon、name、desc、lat、lng
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 输出格式(直接输出 JSON,不要其他文字)
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"alternatives": [
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"icon": "🏔️",
|
|
|
|
|
|
|
|
"name": "替代地点名称",
|
|
|
|
|
|
|
|
"lat": 25.04,
|
|
|
|
|
|
|
|
"lng": 102.71,
|
|
|
|
|
|
|
|
"desc": "简短描述",
|
|
|
|
|
|
|
|
"reason": "推荐理由"
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const REORGANIZE_PROMPT = `你是一个专业的旅行规划助手。用户已经确定了一个行程的站点顺序,现在需要你完善每个站点的详细信息。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 任务
|
|
|
|
|
|
|
|
根据用户提供的站点列表(包含名称和坐标),完善每个站点的行程细节:
|
|
|
|
|
|
|
|
1. 分配合理的 day 标签(从 Day 1 开始,START 为出发日,END 为结束日)
|
|
|
|
|
|
|
|
2. 计算每个站点的合理里程和驾驶时间(根据前后站点距离估算)
|
|
|
|
|
|
|
|
3. 为每个站点编写描述(desc)
|
|
|
|
|
|
|
|
4. 为每个站点安排详细的每日行程(schedule,包含 time、title、content、desc)
|
|
|
|
|
|
|
|
5. 推荐当地美食(foods,每站至少 4 种)
|
|
|
|
|
|
|
|
6. 推荐住宿(hotel)
|
|
|
|
|
|
|
|
7. 提供注意事项(tips)
|
|
|
|
|
|
|
|
8. 如果有顺路的景点,添加到 waypoints
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 重要
|
|
|
|
|
|
|
|
- 第一个站点用 badge "START",最后一个用 "END",中间用 "D1", "D2" 等
|
|
|
|
|
|
|
|
- lat/lng 保持用户提供的坐标不变
|
|
|
|
|
|
|
|
- 图标 (icon) 根据景点类型选择合适 emoji
|
|
|
|
|
|
|
|
- 总里程 totalKm 和总驾驶时间 totalDriveTime 根据各站点累加
|
|
|
|
|
|
|
|
- 路线描述 route 用 → 连接各站点名称
|
|
|
|
|
|
|
|
- 生成 3-5 个行程亮点 highlights
|
|
|
|
|
|
|
|
- 设定一个合理的 budget
|
|
|
|
|
|
|
|
- 直接输出 JSON,不要 markdown 代码块标记
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 输出格式
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"name": "行程名称",
|
|
|
|
|
|
|
|
"route": "昆明→大理→丽江",
|
|
|
|
|
|
|
|
"days": 4,
|
|
|
|
|
|
|
|
"totalKm": 1200,
|
|
|
|
|
|
|
|
"totalDriveTime": 12,
|
|
|
|
|
|
|
|
"budget": "¥3000",
|
|
|
|
|
|
|
|
"highlights": ["亮点1", "亮点2"],
|
|
|
|
|
|
|
|
"points": [
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"name": "地点名称",
|
|
|
|
|
|
|
|
"lat": 25.04,
|
|
|
|
|
|
|
|
"lng": 102.71,
|
|
|
|
|
|
|
|
"day": "出发日/第1天",
|
|
|
|
|
|
|
|
"badge": "START/D1/END",
|
|
|
|
|
|
|
|
"icon": "🏙️",
|
|
|
|
|
|
|
|
"desc": "简短描述",
|
|
|
|
|
|
|
|
"km": "0km",
|
|
|
|
|
|
|
|
"driveTime": "—",
|
|
|
|
|
|
|
|
"schedule": [
|
|
|
|
|
|
|
|
{ "time": "上午", "title": "标题", "content": "行程安排简述", "desc": "详细描述" },
|
|
|
|
|
|
|
|
{ "time": "下午", "title": "标题", "content": "行程安排简述", "desc": "详细描述" },
|
|
|
|
|
|
|
|
{ "time": "晚上", "title": "标题", "content": "行程安排简述", "desc": "详细描述" }
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
"foods": [
|
|
|
|
|
|
|
|
{ "name": "美食名称", "icon": "🍜", "desc": "美食简介" }
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
"waypoints": [
|
|
|
|
|
|
|
|
{ "name": "途径地点", "icon": "📍", "desc": "简短介绍" }
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
"hotel": "推荐住宿",
|
|
|
|
|
|
|
|
"tips": "注意事项"
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
}`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* AI 建议替代目的地
|
|
|
|
|
|
|
|
* @param {Object} point - 当前要替换的站点 { name, lat, lng, desc }
|
|
|
|
|
|
|
|
* @param {Object|null} prevPoint - 上一个站点
|
|
|
|
|
|
|
|
* @param {Object|null} nextPoint - 下一个站点
|
|
|
|
|
|
|
|
* @param {Function} onThinking - 思考过程回调
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
export async function suggestReplacements(point, prevPoint, nextPoint, onThinking = null, context = {}) {
|
|
|
|
|
|
|
|
let contextInfo = `当前站点:${point.name}(${point.lat}, ${point.lng})- ${point.desc || ''}\n`
|
|
|
|
|
|
|
|
if (prevPoint) contextInfo += `上一个站点:${prevPoint.name}(${prevPoint.lat}, ${prevPoint.lng})\n`
|
|
|
|
|
|
|
|
if (nextPoint) contextInfo += `下一个站点:${nextPoint.name}(${nextPoint.lat}, ${nextPoint.lng})\n`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const userMessage = `我需要替换行程中的「${point.name}」,请推荐合适的替代目的地。\n\n${contextInfo}\n请确保替代地点路线顺路且类型匹配。`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const MAX_RETRIES = 2
|
|
|
|
|
|
|
|
let lastError = null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const content = await streamAIRequest([
|
|
|
|
|
|
|
|
{ role: 'system', content: REPLACE_PROMPT },
|
|
|
|
|
|
|
|
{ role: 'user', content: userMessage }
|
|
|
|
|
|
|
|
], onThinking, null, { ...context, eventType: context.eventType || 'replace' })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const parsed = parseJSONFromContent(content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!parsed.alternatives || !Array.isArray(parsed.alternatives) || parsed.alternatives.length === 0) {
|
|
|
|
|
|
|
|
throw new Error('AI 未返回替代地点')
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return parsed.alternatives
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
lastError = e
|
|
|
|
|
|
|
|
if (attempt < MAX_RETRIES) {
|
|
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
throw new Error(`AI 推荐失败:${lastError?.message || '未知错误'}`)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* AI 整理完善整个行程
|
|
|
|
|
|
|
|
* @param {Array} points - 完整站点列表(至少包含 name, lat, lng)
|
|
|
|
|
|
|
|
* @param {Function} onThinking - 思考过程回调
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
export async function reorganizeItinerary(points, onThinking = null, context = {}) {
|
|
|
|
|
|
|
|
const pointsBrief = points.map((p, i) => {
|
|
|
|
|
|
|
|
const badge = i === 0 ? 'START' : (i === points.length - 1 ? 'END' : `D${i}`)
|
|
|
|
|
|
|
|
return `${badge}: ${p.name} (${p.lat}, ${p.lng})${p.desc ? ' - ' + p.desc : ''}`
|
|
|
|
|
|
|
|
}).join('\n')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const userMessage = `请根据以下站点规划完善整个行程的详细信息:\n\n${pointsBrief}\n\n请确保:
|
|
|
|
|
|
|
|
1. 保持站点顺序不变
|
|
|
|
|
|
|
|
2. 保持每个站点的 lat/lng 不变
|
|
|
|
|
|
|
|
3. 完善所有细节(行程安排、美食、住宿、注意事项等)
|
|
|
|
|
|
|
|
4. 合理分配天数和里程`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const MAX_RETRIES = 2
|
|
|
|
|
|
|
|
let lastError = null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const content = await streamAIRequest([
|
|
|
|
|
|
|
|
{ role: 'system', content: REORGANIZE_PROMPT },
|
|
|
|
|
|
|
|
{ role: 'user', content: userMessage }
|
|
|
|
|
|
|
|
], onThinking, null, { ...context, eventType: context.eventType || 'reorganize' })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const parsed = parseJSONFromContent(content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!parsed.points || !Array.isArray(parsed.points) || parsed.points.length === 0) {
|
|
|
|
|
|
|
|
throw new Error('AI 返回结果缺少 points')
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return parsed
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
lastError = e
|
|
|
|
|
|
|
|
if (attempt < MAX_RETRIES) {
|
|
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
throw new Error(`AI 整理失败:${lastError?.message || '未知错误'}`)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const ENRICH_POINT_PROMPT = `你是一个专业的旅行规划助手。用户行程中新增了一个站点,需要你为该站点生成详细的旅行信息。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 任务
|
|
|
|
|
|
|
|
根据新增站点的名称、坐标以及前后站点的信息,生成该站点的详细旅行数据:
|
|
|
|
|
|
|
|
1. 提供该地点的真实 GPS 坐标 (lat/lng)
|
|
|
|
|
|
|
|
2. 选择合适 emoji 图标 (icon)
|
|
|
|
|
|
|
|
3. 编写简短描述 (desc)
|
|
|
|
|
|
|
|
4. 安排详细的每日行程 (schedule,包含 time、title、content、desc 三段时间)
|
|
|
|
|
|
|
|
5. 推荐当地美食 (foods,至少 4 种,含 name、icon、desc)
|
|
|
|
|
|
|
|
6. 推荐住宿 (hotel)
|
|
|
|
|
|
|
|
7. 提供注意事项 (tips)
|
|
|
|
|
|
|
|
8. 推荐途径景点 (waypoints,1-2 个)
|
|
|
|
|
|
|
|
9. 估算从前一站到本站的里程(km)和驾驶时间(driveTime)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 输出格式
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"point": {
|
|
|
|
|
|
|
|
"lat": 25.04,
|
|
|
|
|
|
|
|
"lng": 102.71,
|
|
|
|
|
|
|
|
"icon": "🏯",
|
|
|
|
|
|
|
|
"desc": "简短描述",
|
|
|
|
|
|
|
|
"km": "150km",
|
|
|
|
|
|
|
|
"driveTime": "2.5h",
|
|
|
|
|
|
|
|
"schedule": [
|
|
|
|
|
|
|
|
{ "time": "上午", "title": "标题", "content": "内容", "desc": "详细描述" },
|
|
|
|
|
|
|
|
{ "time": "下午", "title": "标题", "content": "内容", "desc": "详细描述" },
|
|
|
|
|
|
|
|
{ "time": "晚上", "title": "标题", "content": "内容", "desc": "详细描述" }
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
"foods": [
|
|
|
|
|
|
|
|
{ "name": "美食名称", "icon": "🍜", "desc": "简介" }
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
"waypoints": [
|
|
|
|
|
|
|
|
{ "name": "途径点", "icon": "📍", "desc": "简介" }
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
"hotel": "推荐住宿",
|
|
|
|
|
|
|
|
"tips": "注意事项"
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 重要
|
|
|
|
|
|
|
|
- 直接输出 JSON,不要 markdown 代码块标记
|
|
|
|
|
|
|
|
- lat/lng 必须是该地点的真实中国地理坐标
|
|
|
|
|
|
|
|
- 美食至少 4 种,每种含 name、icon、desc
|
|
|
|
|
|
|
|
- 行程安排 3 段时间(上午、下午、晚上)
|
|
|
|
|
|
|
|
- km 和 driveTime 估算从前一站到本站的路程`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* AI 为新增站点生成详细信息
|
|
|
|
|
|
|
|
* @param {Object} point - 新增站点 { name, lat, lng, day }
|
|
|
|
|
|
|
|
* @param {Object|null} prevPoint - 上一个站点
|
|
|
|
|
|
|
|
* @param {Object|null} nextPoint - 下一个站点
|
|
|
|
|
|
|
|
* @param {Function} onThinking - 思考过程回调
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
export async function enrichSinglePoint(point, prevPoint, nextPoint, onThinking = null, context = {}) {
|
|
|
|
|
|
|
|
let contextInfo = `新增站点:${point.name}(${point.lat || 0}, ${point.lng || 0})`
|
|
|
|
|
|
|
|
if (point.day) contextInfo += ` - 所属天数:${point.day}`
|
|
|
|
|
|
|
|
contextInfo += '\n'
|
|
|
|
|
|
|
|
if (prevPoint) contextInfo += `上一个站点:${prevPoint.name}(${prevPoint.lat}, ${prevPoint.lng})\n`
|
|
|
|
|
|
|
|
if (nextPoint) contextInfo += `下一个站点:${nextPoint.name}(${nextPoint.lat}, ${nextPoint.lng})\n`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const userMessage = `请为行程中新增的站点「${point.name}」生成详细的旅行信息。\n\n${contextInfo}\n确保信息准确且与前后站点衔接自然。`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const MAX_RETRIES = 2
|
|
|
|
|
|
|
|
let lastError = null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const content = await streamAIRequest([
|
|
|
|
|
|
|
|
{ role: 'system', content: ENRICH_POINT_PROMPT },
|
|
|
|
|
|
|
|
{ role: 'user', content: userMessage }
|
|
|
|
|
|
|
|
], onThinking, null, { ...context, eventType: context.eventType || 'enrich' })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const parsed = parseJSONFromContent(content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!parsed.point || typeof parsed.point !== 'object') {
|
|
|
|
|
|
|
|
throw new Error('AI 返回结果缺少 point 字段')
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return parsed.point
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
lastError = e
|
|
|
|
|
|
|
|
if (attempt < MAX_RETRIES) {
|
|
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 失败时返回基础数据,不中断流程
|
|
|
|
|
|
|
|
console.warn('[enrichSinglePoint] AI 生成失败:', lastError?.message)
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
|
|
|
}
|
|
|
|
|