diff --git a/.data/trip-planner.db-shm b/.data/trip-planner.db-shm index 70ea03e..15aa23c 100644 Binary files a/.data/trip-planner.db-shm and b/.data/trip-planner.db-shm differ diff --git a/.data/trip-planner.db-wal b/.data/trip-planner.db-wal index 7ac90c2..ba478ec 100644 Binary files a/.data/trip-planner.db-wal and b/.data/trip-planner.db-wal differ diff --git a/server.js b/server.js index e3c2c1e..b1f5606 100644 --- a/server.js +++ b/server.js @@ -44,6 +44,7 @@ function saveConfig(config) { } import cors from 'cors' +import { db } from './src/db/database.js' // 导入路由 import authRoutes from './src/routes/auth.js' @@ -189,6 +190,9 @@ app.post('/api/chat', async (req, res) => { const decoder = new TextDecoder() let totalBytes = 0 let chunkCount = 0 + let sseBuffer = '' + let usageData = null + const startTime = Date.now() while (true) { if (clientDisconnected) { @@ -202,6 +206,24 @@ app.post('/api/chat', async (req, res) => { totalBytes += chunk.length chunkCount++ + // 累积 SSE buffer 并解析 usage + sseBuffer += chunk + const lines = sseBuffer.split('\n') + sseBuffer = lines.pop() || '' + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith('data:') && trimmed !== 'data: [DONE]') { + try { + const json = JSON.parse(trimmed.slice(5).trim()) + if (json.usage) { + usageData = json.usage + } else if (json.choices && json.choices.length === 0 && !usageData) { + // 有些 API 在最后一个 choices=[] 的 chunk 中夹带 usage + } + } catch (e) { /* skip partial lines */ } + } + } + res.write(chunk) if (chunkCount % 10 === 0) { @@ -209,7 +231,53 @@ app.post('/api/chat', async (req, res) => { } } - console.log('[api/chat] Streaming done, total chunks:', chunkCount, 'bytes:', totalBytes) + // 记录使用量到数据库 + const duration = Date.now() - startTime + const { user_id, guest_id, event_type = 'chat' } = req.body + let prompt_tokens = 0 + let completion_tokens = 0 + let total_tokens = 0 + let model = req.body.model || config.model + + if (usageData) { + // DashScope 格式: usage.models[0].{input_tokens, output_tokens} + if (usageData.models && usageData.models[0]) { + prompt_tokens = usageData.models[0].input_tokens || 0 + completion_tokens = usageData.models[0].output_tokens || 0 + total_tokens = prompt_tokens + completion_tokens + } + // OpenAI 兼容格式: usage.{prompt_tokens, completion_tokens, total_tokens} + if (usageData.prompt_tokens !== undefined) { + prompt_tokens = usageData.prompt_tokens + completion_tokens = usageData.completion_tokens || 0 + total_tokens = usageData.total_tokens || (prompt_tokens + completion_tokens) + } + } + + // 如果 API 未返回 token 数(streaming 模式常见),用字符数估算 + if (total_tokens === 0) { + // 估算 input tokens: 每 2 个字符约 1 token + const inputChars = JSON.stringify(req.body.messages || '').length + prompt_tokens = Math.ceil(inputChars / 2) + // 估算 output tokens: 每 2 个字符约 1 token + const outputChars = sseBuffer.length + completion_tokens = Math.ceil(outputChars / 2) + total_tokens = prompt_tokens + completion_tokens + } + + if (user_id || guest_id) { + try { + db.prepare(` + INSERT INTO usage_logs (user_id, guest_id, event_type, model, prompt_tokens, completion_tokens, total_tokens, duration_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(user_id || null, guest_id || null, event_type, model, prompt_tokens, completion_tokens, total_tokens, duration) + console.log(`[api/chat] Usage logged: ${total_tokens} tokens (est), ${duration}ms`) + } catch (err) { + console.error('[api/chat] Failed to log usage:', err.message) + } + } + + console.log('[api/chat] Streaming done, total chunks:', chunkCount, 'bytes:', totalBytes, 'tokens:', total_tokens) res.end() } catch (error) { if (!clientDisconnected) { diff --git a/src/components/ChatInterface.vue b/src/components/ChatInterface.vue index f5aef32..7b824f1 100644 --- a/src/components/ChatInterface.vue +++ b/src/components/ChatInterface.vue @@ -2,36 +2,64 @@
+
- 你好!我是你的行程规划助手。请告诉我:
- • 想去哪里?(单个地点或多个地点组合)
- • 几月出发?几天行程?
- • 交通方式?(自驾/公共交通/步行)
- • 有什么特殊需求?(带老人/小孩/特定景点)
-
- 示例:9月去云南,5天,自驾,带老人 + 你好!我是你的行程规划助手 🗺️
+ 接下来我会和你聊一聊,了解一下你的旅行想法。
+ 比如想去哪里、什么时候出发、玩几天……

+ 不用一次说全,我们慢慢聊~
+
- -
-
- - AI 思考过程 - {{ msg.thinkingExpanded ? '收起' : '展开' }} -
-
{{ msg.thinking }}
-
{{ msg.content }}
- -
- {{ msg.error }} -
+
{{ msg.error }}
+
+
+ + +
+
📋 请确认你的行程需求
+
+ 出行时间 + {{ gatheredInfo.time || '—' }} +
+
+ 出发地 + {{ gatheredInfo.origin || '—' }} +
+
+ 目的地 + {{ gatheredInfo.destination || '—' }} +
+
+ 出行方式 + {{ gatheredInfo.transport || '—' }} +
+
+ 人员组成 + {{ gatheredInfo.travelers || '—' }} +
+
+ 偏好 + {{ gatheredInfo.preferences.join('、') }} +
+
+ 意向地点 + {{ gatheredInfo.intendedSpots }} +
+
+ 禁忌 + {{ gatheredInfo.taboos }} +
+
+ +
@@ -45,7 +73,7 @@
- +
{{ getSchemeLabel(i) }} @@ -60,28 +88,41 @@ - +
diff --git a/src/db/database.js b/src/db/database.js index 7c368ca..770c936 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -75,6 +75,20 @@ db.exec(` created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + -- AI 使用量日志表 + CREATE TABLE IF NOT EXISTS usage_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT, + guest_id TEXT, + event_type TEXT NOT NULL, -- 'chat' | 'gather' | 'enrich' | 'replace' | 'reorganize' + model TEXT, + prompt_tokens INTEGER DEFAULT 0, + completion_tokens INTEGER DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + duration_ms INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + -- 创建索引 CREATE INDEX IF NOT EXISTS idx_plans_user_id ON plans(user_id); CREATE INDEX IF NOT EXISTS idx_plans_guest_id ON plans(guest_id); @@ -84,6 +98,9 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_stats_guest_id ON stats(guest_id); CREATE INDEX IF NOT EXISTS idx_stats_event_type ON stats(event_type); CREATE INDEX IF NOT EXISTS idx_stats_created_at ON stats(created_at); + CREATE INDEX IF NOT EXISTS idx_usage_logs_user_id ON usage_logs(user_id); + CREATE INDEX IF NOT EXISTS idx_usage_logs_event_type ON usage_logs(event_type); + CREATE INDEX IF NOT EXISTS idx_usage_logs_created_at ON usage_logs(created_at); `) // 清理过期游客数据的函数 diff --git a/src/routes/stats.js b/src/routes/stats.js index 38d44f4..5365010 100644 --- a/src/routes/stats.js +++ b/src/routes/stats.js @@ -1,7 +1,7 @@ // 使用统计和管理员路由 import express from 'express' import { db } from '../db/database.js' -import { requireAdmin } from './auth.js' +import { authenticateToken, requireAdmin } from './auth.js' const router = express.Router() @@ -29,19 +29,19 @@ router.post('/event', (req, res) => { }) // 管理员:获取总体统计数据 -router.get('/admin/overview', requireAdmin, (req, res) => { +router.get('/admin/overview', authenticateToken, requireAdmin, (req, res) => { try { // 用户总数 const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get() // 管理员数量 - const adminCount = db.prepare('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']).get() + const adminCount = db.prepare('SELECT COUNT(*) as count FROM users WHERE role = ?').get('admin') // 总计划数 const totalPlans = db.prepare('SELECT COUNT(*) as count FROM plans').get() // 游客计划数(未过期) - const guestPlans = db.prepare('SELECT COUNT(*) as count FROM guest_plans WHERE expires_at > ?', [new Date().toISOString()]).get() + const guestPlans = db.prepare('SELECT COUNT(*) as count FROM guest_plans WHERE expires_at > ?').get(new Date().toISOString()) // 总事件数 const totalEvents = db.prepare('SELECT COUNT(*) as count FROM stats').get() @@ -74,7 +74,7 @@ router.get('/admin/overview', requireAdmin, (req, res) => { }) // 管理员:按类型统计事件 -router.get('/admin/events-by-type', requireAdmin, (req, res) => { +router.get('/admin/events-by-type', authenticateToken, requireAdmin, (req, res) => { try { const { days = 30 } = req.query const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString() @@ -95,7 +95,7 @@ router.get('/admin/events-by-type', requireAdmin, (req, res) => { }) // 管理员:每日活跃趋势 -router.get('/admin/daily-active', requireAdmin, (req, res) => { +router.get('/admin/daily-active', authenticateToken, requireAdmin, (req, res) => { try { const { days = 30 } = req.query @@ -115,7 +115,7 @@ router.get('/admin/daily-active', requireAdmin, (req, res) => { }) // 管理员:用户列表 -router.get('/admin/users', requireAdmin, (req, res) => { +router.get('/admin/users', authenticateToken, requireAdmin, (req, res) => { try { const users = db.prepare(` SELECT u.id, u.username, u.email, u.role, u.created_at, @@ -137,7 +137,7 @@ router.get('/admin/users', requireAdmin, (req, res) => { }) // 管理员:热门行程 -router.get('/admin/popular-plans', requireAdmin, (req, res) => { +router.get('/admin/popular-plans', authenticateToken, requireAdmin, (req, res) => { try { const { limit = 10 } = req.query @@ -159,4 +159,88 @@ router.get('/admin/popular-plans', requireAdmin, (req, res) => { } }) +// ===== 管理员:AI 使用量统计 ===== + +// 各用户 token 使用量 +router.get('/admin/usage/tokens', authenticateToken, requireAdmin, (req, res) => { + try { + const { days = 30 } = req.query + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString() + + const rows = db.prepare(` + SELECT + COALESCE(u.username, '(游客)') as username, + ul.event_type, + SUM(ul.prompt_tokens) as prompt_tokens, + SUM(ul.completion_tokens) as completion_tokens, + SUM(ul.total_tokens) as total_tokens, + COUNT(*) as call_count, + SUM(ul.duration_ms) as total_duration_ms + FROM usage_logs ul + LEFT JOIN users u ON ul.user_id = u.id + WHERE ul.created_at > ? + GROUP BY ul.user_id, ul.event_type + ORDER BY total_tokens DESC + `).all(since) + + const totalTokens = rows.reduce((s, r) => s + r.total_tokens, 0) + const totalCalls = rows.reduce((s, r) => s + r.call_count, 0) + + res.json({ rows, totalTokens, totalCalls, days }) + } catch (err) { + console.error('获取 token 使用量失败:', err) + res.status(500).json({ error: '获取 token 使用量失败' }) + } +}) + +// 每日 token 消耗趋势 +router.get('/admin/usage/daily', authenticateToken, requireAdmin, (req, res) => { + try { + const { days = 30 } = req.query + const daily = db.prepare(` + SELECT + date(created_at) as date, + SUM(total_tokens) as total_tokens, + SUM(prompt_tokens) as prompt_tokens, + SUM(completion_tokens) as completion_tokens, + COUNT(*) as call_count + FROM usage_logs + WHERE created_at > datetime('now', ?) + GROUP BY date(created_at) + ORDER BY date ASC + `).all(`-${days} days`) + + res.json({ daily, days }) + } catch (err) { + console.error('获取每日使用趋势失败:', err) + res.status(500).json({ error: '获取每日使用趋势失败' }) + } +}) + +// 各模型调用统计 +router.get('/admin/usage/models', authenticateToken, requireAdmin, (req, res) => { + try { + const { days = 30 } = req.query + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString() + + const models = db.prepare(` + SELECT + model, + COUNT(*) as call_count, + SUM(total_tokens) as total_tokens, + SUM(duration_ms) as total_duration_ms, + AVG(duration_ms) as avg_duration_ms + FROM usage_logs + WHERE created_at > ? AND model IS NOT NULL + GROUP BY model + ORDER BY call_count DESC + `).all(since) + + res.json({ models, days }) + } catch (err) { + console.error('获取模型统计失败:', err) + res.status(500).json({ error: '获取模型统计失败' }) + } +}) + export default router diff --git a/src/services/aiService.js b/src/services/aiService.js index faa37a3..bd86c82 100644 --- a/src/services/aiService.js +++ b/src/services/aiService.js @@ -42,6 +42,53 @@ JSON 格式如下: 3. 至少 2 个方案,最多 3 个 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 个不同的旅行方案。 ## 重要:输出格式 @@ -69,39 +116,109 @@ JSON 格式如下: "tips": "旅行贴士,如最佳季节、注意事项、必备物品等", "points": [ { - "name": "地点名称", + "name": "昆明", "lat": 25.04, "lng": 102.71, "day": "Day 1", "badge": "START", "icon": "🏙️", - "desc": "该地点简短描述", + "desc": "春城昆明,云南省会", "km": "0km", "driveTime": "—", "schedule": [ { "time": "上午", - "title": "标题", - "content": "行程安排简述", - "desc": "详细描述" + "title": "抵达昆明", + "content": "抵达昆明,入住酒店", + "desc": "建议选择翠湖附近的酒店" } ], "foods": [ { - "name": "美食名称", + "name": "过桥米线", "icon": "🍜", - "desc": "美食简介" + "desc": "云南经典美食" } ], "waypoints": [ { - "name": "途径地点名称", + "name": "滇池", "icon": "📍", - "desc": "简短介绍" + "desc": "云南最大淡水湖" } ], - "hotel": "推荐住宿", - "tips": "注意事项" + "hotel": "昆明翠湖酒店", + "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 8. waypoints 中每个对象必须包含 name、icon、desc 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 = `你是一个专业的旅行规划助手。用户已经输入了他/她的行程安排,你需要: 1. 解析用户的行程安排 @@ -197,7 +315,101 @@ JSON 格式如下: 3. 即使行程合理也要生成两个方案(优化版和原版) 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(/[\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 const apiUrl = import.meta.env.DEV ? 'http://localhost:3001/api/chat' : '/api/chat' const response = await fetch(apiUrl, { @@ -210,7 +422,10 @@ export async function chatWithAI(userMessage, onThinking = null) { { role: 'system', content: SYSTEM_PROMPT }, { 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 -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' console.log('[streamAI] Fetching:', apiUrl) const response = await fetch(apiUrl, { method: 'POST', 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 }) 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} 出行天数:${userInput.days}天 出发日期:${userInput.startDate} @@ -458,7 +679,7 @@ export async function quickPlan(userInput, onThinking = null, signal = null) { const content = await streamAIRequest([ { role: 'system', content: QUICK_PLAN_PROMPT }, { role: 'user', content: userMessage } - ], onThinking, signal) + ], onThinking, signal, { ...context, eventType: context.eventType || 'quick_plan' }) const parsed = parseJSONFromContent(content) @@ -466,11 +687,18 @@ export async function quickPlan(userInput, onThinking = null, signal = null) { throw new Error('AI 返回结果缺少 schemes') } - // 验证每个 scheme 都有 points + // 验证每个 scheme 都有足够的 points for (let i = 0; i < parsed.schemes.length; i++) { const scheme = parsed.schemes[i] - if (!scheme.points || !Array.isArray(scheme.points) || scheme.points.length === 0) { - throw new Error(`方案 ${i + 1} (${scheme.name || '未知'}) 缺少 points 数据`) + if (!scheme.points || !Array.isArray(scheme.points) || scheme.points.length < 2) { + 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}`) } @@ -502,7 +730,7 @@ export async function quickPlan(userInput, onThinking = null, signal = null) { 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 = `我的行程安排: ${userInput.itinerary} @@ -520,7 +748,7 @@ ${userInput.itinerary} const content = await streamAIRequest([ { role: 'system', content: CUSTOM_PLAN_PROMPT }, { role: 'user', content: userMessage } - ], onThinking) + ], onThinking, null, { ...context, eventType: context.eventType || 'custom_plan' }) const parsed = parseJSONFromContent(content) @@ -541,3 +769,267 @@ ${userInput.itinerary} 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 +} diff --git a/src/stores/itinerary.js b/src/stores/itinerary.js index 8694377..ff0e071 100644 --- a/src/stores/itinerary.js +++ b/src/stores/itinerary.js @@ -75,22 +75,56 @@ export const useItineraryStore = defineStore('itinerary', () => { function nextStep() { if (currentStep.value < points.value.length - 1) currentStep.value++ } function prevStep() { if (currentStep.value > 0) currentStep.value-- } + function recalcBadges() { + if (points.value.length === 0) return + // First point always START + points.value[0].badge = 'START' + + // Last point always END + if (points.value.length > 1) { + points.value[points.value.length - 1].badge = 'END' + } + + // Middle points: derive badge from day field + for (let i = 1; i < points.value.length - 1; i++) { + const day = points.value[i].day + if (day) { + const match = day.match(/Day\s*(\d+)/i) + if (match) { + points.value[i].badge = `D${match[1]}` + } else { + points.value[i].badge = `D${i}` + } + } else { + points.value[i].badge = `D${i}` + } + } + } + function deletePoint(index) { if (index <= 0 || index >= points.value.length - 1) return points.value.splice(index, 1) routeSegments.value = [] if (currentStep.value >= points.value.length) currentStep.value = points.value.length - 1 + recalcBadges() checkRouteOptimization() } function insertPoint(beforeIndex, newPoint) { + // Determine day: inherit from previous point, or use provided day + let day = newPoint.day || '' + if (!day && beforeIndex > 0 && beforeIndex <= points.value.length) { + day = points.value[beforeIndex - 1].day || '' + } + points.value.splice(beforeIndex, 0, { id: 'p_' + Date.now(), name: newPoint.name, lat: newPoint.lat, lng: newPoint.lng, - day: '', badge: '', km: '', driveTime: '', desc: newPoint.desc, + day: day, badge: '', km: '', driveTime: '', desc: newPoint.desc, icon: newPoint.icon || '📍', images: [], heroImage: '', schedule: [], foods: [], hotel: '', tips: '' }) routeSegments.value = [] + recalcBadges() checkRouteOptimization() } @@ -111,6 +145,7 @@ export const useItineraryStore = defineStore('itinerary', () => { points.value.splice(toIndex, 0, item) currentStep.value = toIndex routeSegments.value = [] + recalcBadges() checkRouteOptimization() } @@ -200,6 +235,37 @@ export const useItineraryStore = defineStore('itinerary', () => { return } + // Defensive: 如果 points 数量太少但有 daysDetail,从 daysDetail 补充 points + if (scheme.points.length < 2 && scheme.daysDetail && scheme.daysDetail.length >= 2) { + console.warn(`[loadFromAI] points 只有 ${scheme.points.length} 个,从 daysDetail (${scheme.daysDetail.length} 天) 补充`) + const existingPoints = [...scheme.points] + const fallbackLat = existingPoints[0]?.lat || 25.04 + const fallbackLng = existingPoints[0]?.lng || 102.71 + scheme.points = scheme.daysDetail.map((day, di) => { + const existing = existingPoints.find(p => p.name && day.location && p.name === day.location) + return existing || { + name: day.location || `第${di + 1}站`, + lat: fallbackLat + (di * 0.5), + lng: fallbackLng + (di * 0.3), + day: `Day ${di + 1}`, + badge: di === 0 ? 'START' : (di === scheme.daysDetail.length - 1 ? 'END' : `D${di}`), + icon: ['🏙️', '🌿', '🏘️', '🏔️', '🏯', '💧', '🌾', '⛰️', '🏖️', '🛕', '🌸', '🏝️', '✈️'][di % 13], + desc: day.desc || '', + km: day.km || (di === 0 ? '0km' : `${Math.round(Math.random() * 300 + 50)}km`), + driveTime: day.driveTime || (di === 0 ? '—' : `${Math.round(Math.random() * 5 + 1)}h`), + schedule: (day.schedule || [ + { time: '上午', title: '抵达目的地', content: '抵达目的地,开始探索' }, + { time: '下午', title: '游览景点', content: '游览主要景点' }, + { time: '晚上', title: '品尝美食', content: '品尝当地美食' } + ]), + foods: day.foods || ['当地特色美食'], + hotel: day.hotel || '推荐酒店', + tips: day.tips || '注意安全,提前预订', + } + }) + console.log(`[loadFromAI] 补充后 points 数量: ${scheme.points.length}`) + } + try { const icons = ['🏙️', '🌿', '🏘️', '🏔️', '🏯', '💧', '🌾', '⛰️', '🏖️', '🛕', '🌸', '🏝️', '✈️'] const newPoints = scheme.points.map((p, i) => { @@ -306,7 +372,7 @@ export const useItineraryStore = defineStore('itinerary', () => { hoveredReplacement, quickSchemes, historySchemeGroups, activeSchemeIndex, startPoint, endPoint, totalKm, totalDriveTime, travelDays, currentPoint, replacementOptions, setPhase, setMode, setPlanningMode, resetToModeSelection, setCurrentStep, nextStep, prevStep, - deletePoint, insertPoint, replacePoint, reorderPoints, resetToSample, + deletePoint, insertPoint, replacePoint, reorderPoints, resetToSample, recalcBadges, fetchRealRoutes, checkRouteOptimization, autoOptimizeRoute, dismissOptimization, setHoveredReplacement, loadFromAI, loadSchemeByIndex, saveSchemesToStore, setActiveSchemeIndex, getStoredSchemes } diff --git a/src/views/AdminPage.vue b/src/views/AdminPage.vue index 0250f71..373d4e3 100644 --- a/src/views/AdminPage.vue +++ b/src/views/AdminPage.vue @@ -1,7 +1,7 @@ @@ -103,6 +157,8 @@ const loading = ref(true) const overview = ref({}) const events = ref([]) const users = ref([]) +const usageStats = ref({ rows: [], totalTokens: 0, totalCalls: 0 }) +const modelStats = ref({ models: [] }) const totalEventsCount = ref(0) @@ -123,21 +179,28 @@ async function loadData() { const API_BASE = 'http://localhost:3001/api' const headers = { 'Authorization': `Bearer ${authStore.token}` } - // 加载概览数据 - const overviewRes = await fetch(`${API_BASE}/stats/admin/overview`, { headers }) - const overviewData = await overviewRes.json() - overview.value = overviewData.overview + async function apiFetch(url) { + const res = await fetch(url, { headers }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || `请求失败: ${res.status}`) + return data + } + + // 并行加载所有数据 + const [overviewData, eventsData, usersData, usageData, modelsData] = await Promise.all([ + apiFetch(`${API_BASE}/stats/admin/overview`), + apiFetch(`${API_BASE}/stats/admin/events-by-type?days=30`), + apiFetch(`${API_BASE}/stats/admin/users`), + apiFetch(`${API_BASE}/stats/admin/usage/tokens?days=30`), + apiFetch(`${API_BASE}/stats/admin/usage/models?days=30`) + ]) - // 加载事件统计 - const eventsRes = await fetch(`${API_BASE}/stats/admin/events-by-type?days=30`, { headers }) - const eventsData = await eventsRes.json() + overview.value = overviewData.overview events.value = eventsData.events totalEventsCount.value = eventsData.events.reduce((sum, e) => sum + e.count, 0) - - // 加载用户列表 - const usersRes = await fetch(`${API_BASE}/stats/admin/users`, { headers }) - const usersData = await usersRes.json() users.value = usersData.users + usageStats.value = usageData + modelStats.value = modelsData } catch (err) { console.error('加载数据失败:', err) alert('加载数据失败') @@ -146,6 +209,18 @@ async function loadData() { } } +function formatNumber(n) { + if (!n && n !== 0) return '—' + return n.toLocaleString() +} + +function formatDuration(ms) { + if (!ms) return '—' + if (ms < 1000) return ms + 'ms' + if (ms < 60000) return (ms / 1000).toFixed(1) + 's' + return Math.floor(ms / 60000) + 'm ' + Math.floor((ms % 60000) / 1000) + 's' +} + function getEventLabel(type) { const labels = { 'page_view': '页面访问', @@ -156,7 +231,15 @@ function getEventLabel(type) { 'scheme_select': '选择方案', 'user_register': '用户注册', 'user_login': '用户登录', - 'guest_data_migrated': '游客数据迁移' + 'guest_data_migrated': '游客数据迁移', + 'chat': 'AI 对话', + 'gather': '需求收集', + 'generate': '方案生成', + 'quick_plan': '快速规划', + 'custom_plan': '自定义规划', + 'replace': 'AI 替换', + 'enrich': 'AI 充实', + 'reorganize': 'AI 重排' } return labels[type] || type } @@ -198,6 +281,16 @@ function formatDate(dateStr) { color: #2d3436; } +.admin-header h1 .header-slogan { + font-size: 14px; + font-weight: 400; + color: #b2bec3; + margin-left: 12px; + padding-left: 12px; + border-left: 1px solid #dfe6e9; + letter-spacing: 0.5px; +} + .back-btn { background: #6c5ce7; color: #fff; @@ -310,4 +403,42 @@ function formatDate(dateStr) { background: #e8e4ff; color: #6c5ce7; } + +/* Usage stats */ +.usage-summary { + display: flex; + gap: 16px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.usage-stat { + background: #f5f7fa; + border-radius: 10px; + padding: 14px 20px; + text-align: center; + min-width: 120px; + flex: 1; +} + +.usage-number { + display: block; + font-size: 28px; + font-weight: 700; + color: #6c5ce7; +} + +.usage-label { + display: block; + font-size: 12px; + color: #636e72; + margin-top: 4px; +} + +.empty-hint { + text-align: center; + padding: 30px; + color: #b2bec3; + font-size: 14px; +} diff --git a/src/views/HomePage.vue b/src/views/HomePage.vue index 6c86620..80701c0 100644 --- a/src/views/HomePage.vue +++ b/src/views/HomePage.vue @@ -2,7 +2,7 @@
@@ -232,6 +233,15 @@ watch(() => store.currentStep, (newIdx, oldIdx) => { z-index: 100; } +.wb-slogan { + font-size: 12px; + color: #b2bec3; + font-weight: 400; + padding: 0 12px; + letter-spacing: 0.5px; + white-space: nowrap; +} + .wb-plan-selector { display: flex; align-items: center; diff --git a/vite.config.js b/vite.config.js index 0532775..0bcb220 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,6 +5,12 @@ export default defineConfig({ plugins: [vue()], base: './', server: { - historyApiFallback: true + historyApiFallback: true, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true + } + } } })