|
|
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 <thinking>...</thinking> tags
|
|
|
cleaned = cleaned.replace(/<thinking>[\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(/<thinking>[\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
|
|
|
}
|