You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

416 lines
12 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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某天行程太紧建议...", "建议2A到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
}