const SYSTEM_PROMPT = `你是一个专业的旅行规划助手。你需要根据用户的需求规划行程。
## 重要:输出格式
你必须直接输出 JSON,不要包含任何 markdown 代码块标记(如 \`\`\`json),不要在 JSON 前后添加任何额外文字。
JSON 格式如下:
{
"thinking": "你的思考过程",
"schemes": [
{
"name": "方案名称",
"route": "路线描述",
"days": 4,
"totalKm": 1200,
"totalDriveTime": 12,
"budget": "¥3000",
"highlights": ["亮点1", "亮点2"],
"points": [
{
"name": "地点名称",
"lat": 25.04,
"lng": 102.71,
"day": "Day 1",
"badge": "START",
"icon": "🏙️",
"desc": "简短描述",
"km": "0km",
"driveTime": "—",
"schedule": [{"time": "上午", "content": "行程"}],
"foods": ["美食"],
"hotel": "住宿",
"tips": "注意事项"
}
]
}
]
}
注意:
1. points 第一个用 START badge,中间用 D1/D2,最后一个用 END
2. lat/lng 必须是真实中国地理坐标
3. 至少 2 个方案,最多 3 个
4. 直接输出 JSON,不要其他内容`
const QUICK_PLAN_PROMPT = `你是一个专业的旅行规划助手。根据用户指定的目的地和天数,快速生成 3 个不同的旅行方案。
## 重要:输出格式
你必须直接输出 JSON,不要包含任何 markdown 代码块标记,不要在 JSON 前后添加任何额外文字。
JSON 格式如下:
{
"thinking": "你的思考过程",
"schemes": [
{
"name": "方案名称",
"route": "路线描述,如:昆明→大理→丽江→香格里拉",
"days": 5,
"totalKm": 800,
"totalDriveTime": 10,
"budget": "¥3000",
"highlights": ["亮点1", "亮点2", "亮点3"],
"daysDetail": [
{
"location": "当日所在地",
"desc": "当日行程描述",
"spots": ["景点1", "景点2", "景点3"]
}
],
"tips": "旅行贴士,如最佳季节、注意事项、必备物品等",
"points": [
{
"name": "地点名称",
"lat": 25.04,
"lng": 102.71,
"day": "Day 1",
"badge": "START",
"icon": "🏙️",
"desc": "该地点简短描述",
"km": "0km",
"driveTime": "—",
"schedule": [{"time": "上午", "content": "行程安排"}],
"foods": ["当地美食"],
"hotel": "推荐住宿",
"tips": "注意事项"
}
]
}
]
}
要求:
1. 3 个方案风格不同(如:经典路线、深度游、小众路线)
2. lat/lng 必须是真实中国地理坐标
3. 行程安排要合理,每天 1-2 个地点
4. daysDetail 数组长度必须等于 days,每天一个对象
5. 直接输出 JSON,不要其他内容`
const CUSTOM_PLAN_PROMPT = `你是一个专业的旅行规划助手。用户已经输入了他/她的行程安排,你需要:
1. 解析用户的行程安排
2. 评估行程是否合理(时间是否充裕、路线是否顺畅、景点搭配是否合理)
3. 如果不合理,给出优化建议和优化后的方案
4. 同时也生成一个按用户原始输入直接生成的方案
## 重要:输出格式
你必须直接输出 JSON,不要包含任何 markdown 代码块标记,不要在 JSON 前后添加任何额外文字。
JSON 格式如下:
{
"thinking": "你的分析过程",
"evaluation": {
"isReasonable": true,
"summary": "整体评价:行程基本合理/存在以下问题...",
"suggestions": ["建议1:某天行程太紧,建议...", "建议2:A到B距离较远,建议..."]
},
"optimizedScheme": {
"name": "优化方案",
"route": "优化后的路线",
"days": 4,
"totalKm": 800,
"totalDriveTime": 10,
"budget": "¥3000",
"highlights": ["亮点1", "亮点2"],
"points": [
{
"name": "地点名称",
"lat": 25.04,
"lng": 102.71,
"day": "Day 1",
"badge": "START",
"icon": "🏙️",
"desc": "描述",
"km": "0km",
"driveTime": "—",
"schedule": [{"time": "上午", "content": "行程"}],
"foods": ["美食"],
"hotel": "住宿",
"tips": "注意事项"
}
]
},
"originalScheme": {
"name": "原始方案(按你的输入生成)",
"route": "原始路线",
"days": 4,
"totalKm": 900,
"totalDriveTime": 12,
"budget": "¥3500",
"highlights": ["亮点1", "亮点2"],
"points": [
{
"name": "地点名称",
"lat": 25.04,
"lng": 102.71,
"day": "Day 1",
"badge": "START",
"icon": "🏙️",
"desc": "描述",
"km": "0km",
"driveTime": "—",
"schedule": [{"time": "上午", "content": "行程"}],
"foods": ["美食"],
"hotel": "住宿",
"tips": "注意事项"
}
]
}
}
注意:
1. lat/lng 必须是真实中国地理坐标
2. optimizedScheme 和 originalScheme 都要包含完整的 points 数组
3. 即使行程合理也要生成两个方案(优化版和原版)
4. 直接输出 JSON,不要其他内容`
export async function chatWithAI(userMessage, onThinking = null) {
// 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 response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userMessage }
],
stream: true
})
})
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 || {}
// Support both reasoning_content (thinking models) and content
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
}
}
}
return parseAIResponse(fullContent || reasoningContent)
}
function parseAIResponse(content) {
// Remove markdown code block markers if present
let cleaned = content.trim()
// Remove ```json ... ``` or ``` ... ```
const codeBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/)
if (codeBlockMatch) {
cleaned = codeBlockMatch[1].trim()
}
// Remove any ... tags
cleaned = cleaned.replace(/[\s\S]*?<\/thinking>/g, '').trim()
// Try to find JSON object
// Find the first { and last } to extract the JSON
const firstBrace = cleaned.indexOf('{')
const lastBrace = cleaned.lastIndexOf('}')
if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) {
throw new Error('AI 未返回有效的 JSON 数据')
}
const jsonStr = cleaned.substring(firstBrace, lastBrace + 1)
try {
const parsed = JSON.parse(jsonStr)
// Validate required fields
if (!parsed.schemes || !Array.isArray(parsed.schemes) || parsed.schemes.length === 0) {
throw new Error('AI 返回结果缺少 schemes')
}
return parsed
} catch (e) {
if (e.message.includes('缺少 schemes')) throw e
console.error('JSON parse error:', e)
console.error('Raw content:', cleaned.substring(0, 200))
throw new Error('AI 返回的 JSON 格式有误,请重试')
}
}
// Shared streaming function
async function streamAIRequest(messages, onThinking = null) {
const apiUrl = import.meta.env.DEV ? 'http://localhost:3001/api/chat' : '/api/chat'
console.log('[streamAI] Fetching:', apiUrl)
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, stream: true })
})
console.log('[streamAI] Response status:', response.status)
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 = ''
let chunkCount = 0
let lastUpdate = Date.now()
while (true) {
const { done, value } = await reader.read()
if (done) {
console.log('[streamAI] Done, total chunks:', chunkCount)
break
}
chunkCount++
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)
if (Date.now() - lastUpdate > 2000) {
console.log('[streamAI] reasoning_length:', reasoningContent.length)
lastUpdate = Date.now()
}
}
}
const chunk = delta.content || ''
if (chunk) fullContent += chunk
} catch (e) {
// Skip malformed JSON
}
}
}
console.log('[streamAI] fullContent:', fullContent.length, 'reasoningContent:', reasoningContent.length)
return fullContent || reasoningContent
}
function parseJSONFromContent(content) {
let cleaned = content.trim()
const codeBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/)
if (codeBlockMatch) cleaned = codeBlockMatch[1].trim()
cleaned = cleaned.replace(/[\s\S]*?<\/thinking>/g, '').trim()
const firstBrace = cleaned.indexOf('{')
const lastBrace = cleaned.lastIndexOf('}')
if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) {
throw new Error('AI 未返回有效的 JSON 数据')
}
const jsonStr = cleaned.substring(firstBrace, lastBrace + 1)
try {
return JSON.parse(jsonStr)
} catch (e) {
console.error('JSON parse error:', e)
throw new Error('AI 返回的 JSON 格式有误,请重试')
}
}
export async function quickPlan(userInput, onThinking = null) {
const userMessage = `目的地:${userInput.destination}
出行天数:${userInput.days}天
出发日期:${userInput.startDate}
出行方式:${userInput.transport}
其他需求:${userInput.extraNeeds}
请为我生成 3 个不同风格的旅行方案。`
const content = await streamAIRequest([
{ role: 'system', content: QUICK_PLAN_PROMPT },
{ role: 'user', content: userMessage }
], onThinking)
const parsed = parseJSONFromContent(content)
if (!parsed.schemes || !Array.isArray(parsed.schemes) || parsed.schemes.length === 0) {
throw new Error('AI 返回结果缺少 schemes')
}
return parsed
}
export async function customPlan(userInput, onThinking = null) {
const userMessage = `我的行程安排:
${userInput.itinerary}
出行方式:${userInput.transport}
同行人员:${userInput.companions}
请评估我的行程是否合理,并给出优化建议和两个方案(优化版和原版)。`
const content = await streamAIRequest([
{ role: 'system', content: CUSTOM_PLAN_PROMPT },
{ role: 'user', content: userMessage }
], onThinking)
const parsed = parseJSONFromContent(content)
if (!parsed.evaluation) {
throw new Error('AI 返回结果缺少 evaluation')
}
return parsed
}