diff --git a/apps/music/frontend/src/lib/api.js b/apps/music/frontend/src/lib/api.js index e70eec5..6219ccd 100644 --- a/apps/music/frontend/src/lib/api.js +++ b/apps/music/frontend/src/lib/api.js @@ -133,6 +133,47 @@ export async function streamChat(pieceId, message, onDelta, signal) { return { ok: true } } +// ---- inspire ---- + +export async function streamInspire(hint, onDelta, signal) { + const resp = await fetch('/api/inspire', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hint: hint || null }), + signal, + }) + if (!resp.ok) { + const text = await resp.text().catch(() => '') + return { ok: false, error: text || `${resp.status}` } + } + const reader = resp.body.getReader() + const dec = new TextDecoder() + let buf = '' + let lastEvent = 'message' + let errorMsg = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + buf += dec.decode(value, { stream: true }) + let idx + while ((idx = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, idx) + buf = buf.slice(idx + 1) + if (line.startsWith('event:')) { + lastEvent = line.slice(6).trim() + } else if (line.startsWith('data:')) { + const data = line.slice(5).replace(/^ /, '') + if (lastEvent === 'error') errorMsg = data + else if (lastEvent !== 'done') onDelta(data) + } else if (line === '') { + lastEvent = 'message' + } + } + } + if (errorMsg) return { ok: false, error: errorMsg } + return { ok: true } +} + // ---- tags ---- export function listTags() { diff --git a/apps/music/frontend/src/views/PlayerView.vue b/apps/music/frontend/src/views/PlayerView.vue index 131fe69..7726408 100644 --- a/apps/music/frontend/src/views/PlayerView.vue +++ b/apps/music/frontend/src/views/PlayerView.vue @@ -9,6 +9,7 @@ placeholder="搜索曲目 / 歌手" /> {{ filtered.length }} / {{ pieces.length }} 首 + @@ -258,6 +259,32 @@ + +
+
+
+ 💡 今天练什么 + +
+
+ + +
+
+

{{ inspireError }}

+
+
+
mdLite(inspireText.value)) + +// 极简 markdown:**粗体** + 列表 + 换行 → html +function mdLite(s) { + if (!s) return '

点「换一批」让 LLM 给你推几首

' + // 转义 html,保留我们后面要插的 tag + let h = s + .replace(/&/g, '&') + .replace(//g, '>') + // **bold** + h = h.replace(/\*\*(.+?)\*\*/g, '$1') + // 行首 - / * 列表 + h = h.replace(/(^|\n)\s*[-*]\s+/g, '$1• ') + // 段落间双换行 →

+ return h.split(/\n{2,}/).map(p => `

${p.replace(/\n/g, '
')}

`).join('') +} + +function openInspire() { + inspireOpen.value = true + if (!inspireText.value) runInspire() +} +function closeInspire() { + if (inspireAbort) { try { inspireAbort.abort() } catch {} ; inspireAbort = null } + inspireRunning.value = false + inspireOpen.value = false +} + +async function runInspire() { + if (inspireRunning.value) return + inspireText.value = '' + inspireError.value = '' + inspireRunning.value = true + const ctrl = new AbortController() + inspireAbort = ctrl + try { + const r = await streamInspire(inspireHint.value.trim(), (delta) => { + inspireText.value += delta + }, ctrl.signal) + if (!r.ok) inspireError.value = r.error || '出错' + } catch (e) { + if (e.name !== 'AbortError') inspireError.value = e.message || String(e) + } finally { + inspireRunning.value = false + inspireAbort = null + } +} + // chord —— 两个 mode 各自独立 state const chordStates = ref({ letters: 'idle', functional: 'idle' }) const chordErrors = ref({ letters: '', functional: '' }) @@ -954,6 +1037,104 @@ onBeforeUnmount(() => { } .topbar .search:focus { border-color: var(--accent-strong); } .topbar .count { color: var(--text-mute); font-size: 12px; white-space: nowrap; } +.btn-inspire { + width: 36px; height: 36px; + border-radius: 50%; + background: rgba(192, 132, 252, 0.15); + color: var(--accent); + font-size: 16px; + display: inline-flex; align-items: center; justify-content: center; +} +.btn-inspire:hover { background: rgba(192, 132, 252, 0.3); } + +.ins-overlay { + position: fixed; + inset: 0; + z-index: 300; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 60px 16px 16px; + backdrop-filter: blur(2px); +} +.ins-modal { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + width: 100%; + max-width: 640px; + max-height: calc(100vh - 76px); + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 12px 40px rgba(0,0,0,0.5); +} +.ins-head { + padding: 14px 18px; + font-size: 16px; + font-weight: 600; + border-bottom: 1px solid var(--border-soft); + display: flex; + justify-content: space-between; + align-items: center; + color: var(--accent); +} +.ins-close { + font-size: 16px; + color: var(--text-mute); + width: 28px; height: 28px; + border-radius: 50%; +} +.ins-close:hover { background: rgba(255,255,255,0.06); color: var(--text); } + +.ins-hint-row { + display: flex; + gap: 8px; + padding: 12px 18px; + border-bottom: 1px solid var(--border-soft); +} +.ins-hint { + flex: 1; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 12px; + font-size: 13px; + color: var(--text); +} +.ins-hint:focus { border-color: var(--accent-strong); outline: none; } +.ins-go { + background: var(--accent-strong); + color: #fff; + padding: 8px 16px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + white-space: nowrap; +} +.ins-go:hover:not(:disabled) { background: var(--accent); } + +.ins-body { + flex: 1; + overflow-y: auto; + padding: 16px 18px 24px; + font-size: 14px; + line-height: 1.7; + color: var(--text); +} +.ins-body :deep(p) { margin: 0 0 8px; } +.ins-body :deep(b) { color: var(--accent); } +.ins-body :deep(.ins-empty) { color: var(--text-mute); text-align: center; padding: 40px 0; } +.ins-err { + margin: 0 18px 16px; + color: var(--accent-red); + background: rgba(239,68,68,0.08); + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; +} + .topbar .btn-add { width: 36px; height: 36px; border-radius: 50%; diff --git a/apps/music/k8s/all.yaml b/apps/music/k8s/all.yaml index a9ff71b..8ff1949 100644 --- a/apps/music/k8s/all.yaml +++ b/apps/music/k8s/all.yaml @@ -62,6 +62,11 @@ spec: secretKeyRef: name: chat-creds key: token + - name: TAVILY_TOKEN + valueFrom: + secretKeyRef: + name: tavily-creds + key: token readinessProbe: httpGet: path: /healthz diff --git a/apps/music/src/main.rs b/apps/music/src/main.rs index f53cab1..9d8d5c9 100644 --- a/apps/music/src/main.rs +++ b/apps/music/src/main.rs @@ -49,6 +49,8 @@ struct AppState { chat_gateway: String, chat_token: String, chat_model: String, + /// Tavily 网络搜索 token(给灵感推荐 endpoint 用)。 + tavily_token: String, } #[tokio::main] @@ -142,6 +144,7 @@ async fn main() -> std::io::Result<()> { let chat_token = std::env::var("CHAT_TOKEN").unwrap_or_default(); let chat_model = std::env::var("CHAT_MODEL").unwrap_or_else(|_| "gemma-4-31b-it".into()); + let tavily_token = std::env::var("TAVILY_TOKEN").unwrap_or_default(); // 关键:reqwest 默认 timeout 不要给 chat 用 —— chat stream 必须能跑很久。 // 对 chord sidecar 的小请求另外用 .timeout() per-request。 let http = reqwest::Client::builder() @@ -156,6 +159,7 @@ async fn main() -> std::io::Result<()> { chat_gateway, chat_token, chat_model, + tavily_token, }; let api = Router::new() @@ -171,6 +175,7 @@ async fn main() -> std::io::Result<()> { "/pieces/:id/chat", get(list_chat).post(post_chat).delete(clear_chat), ) + .route("/inspire", post(post_inspire)) .route( "/pieces/:id/attachments", post(upload_attachments).layer(DefaultBodyLimit::max(REQUEST_BYTES)), @@ -1080,6 +1085,286 @@ async fn playlist_remove_piece( Ok(JsonResp(json!({ "ok": true }))) } +// ---------- handlers: inspire (今天练什么) ---------- + +/// keyword pool —— 每次按按钮随机抽 1 个,再加自由 hint,配合 Tavily +/// 让搜索结果天然多变;同时高 temperature gemma 输出,避免推荐固定。 +const INSPIRE_KEYWORDS: &[&str] = &[ + "2026 华语流行 推荐", + "豆瓣高分 华语 2025", + "B 站 翻唱 热门", + "适合 吉他 弹唱 入门", + "指弹 吉他 名曲 推荐", + "广东话 流行 经典 推荐", + "民谣 治愈系 中文", + "宝藏 华语 歌手", + "华语 R&B 推荐", + "国风 流行 融合", + "近期 KTV 热门 华语", + "伤感 慢歌 弹唱", + "摇滚 简单 三和弦 弹唱", + "经典 老歌 重听", + "lounge 爵士 中文", + "独立 音乐 newcomer", + "华语 新人 2025", + "钢琴 弹唱 抒情", + "城市 民谣 推荐", + "电子 dream pop 中文", + "爵士 标准曲 入门", + "中文 朋克 lo-fi", + "蓝调 入门 学吉他", +]; + +#[derive(Deserialize, Default)] +struct InspireBody { + /// 用户附加意图,比如"想练快歌" / "今晚陪儿子" / "新东西"。可空。 + hint: Option, +} + +#[derive(Serialize)] +struct PieceHint { + title: String, + artist: String, + play_count: i64, +} + +async fn post_inspire( + State(s): State, + JsonResp(body): JsonResp, +) -> Result>>, AppError> { + if s.chat_token.is_empty() { + return Err(AppError::bad_request("CHAT_TOKEN not configured")); + } + + // 1) 随机选 1 个 keyword + let now_ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let kw = { + let idx = (now_ts as usize) % INSPIRE_KEYWORDS.len(); + INSPIRE_KEYWORDS[idx] + }; + let hint = body + .hint + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()); + let query = match hint { + Some(h) => format!("{kw} · 角度:{h}"), + None => kw.to_string(), + }; + + // 2) Tavily 搜热点(5 条;失败不致命) + let web_results = if s.tavily_token.is_empty() { + Vec::new() + } else { + match s + .http + .post("https://api.tavily.com/search") + .timeout(std::time::Duration::from_secs(10)) + .json(&json!({ + "api_key": s.tavily_token, + "query": query, + "max_results": 5, + "search_depth": "basic", + "include_answer": false, + })) + .send() + .await + { + Ok(r) if r.status().is_success() => { + let v: Value = r.json().await.unwrap_or(json!({})); + v.get("results") + .and_then(|x| x.as_array()) + .cloned() + .unwrap_or_default() + } + Ok(r) => { + tracing::warn!(status = %r.status(), "tavily non-200"); + Vec::new() + } + Err(e) => { + tracing::warn!(error = %e, "tavily call failed"); + Vec::new() + } + } + }; + + // 3) 用户曲库画像(top played / least / recent) + let (recent, top_played, least_played, tags_top, n_total) = { + let conn = s.db.lock().unwrap(); + let recent: Vec = conn + .prepare( + "SELECT title, COALESCE(artist, ''), play_count FROM pieces + WHERE last_played_at IS NOT NULL + ORDER BY last_played_at DESC LIMIT 5", + )? + .query_map([], |r| Ok(PieceHint { + title: r.get(0)?, artist: r.get(1)?, play_count: r.get(2)?, + }))? + .collect::, _>>()?; + let top: Vec = conn + .prepare( + "SELECT title, COALESCE(artist, ''), play_count FROM pieces + WHERE play_count > 0 ORDER BY play_count DESC LIMIT 5", + )? + .query_map([], |r| Ok(PieceHint { + title: r.get(0)?, artist: r.get(1)?, play_count: r.get(2)?, + }))? + .collect::, _>>()?; + let least: Vec = conn + .prepare( + "SELECT title, COALESCE(artist, ''), play_count FROM pieces + ORDER BY play_count ASC, RANDOM() LIMIT 5", + )? + .query_map([], |r| Ok(PieceHint { + title: r.get(0)?, artist: r.get(1)?, play_count: r.get(2)?, + }))? + .collect::, _>>()?; + let tags: Vec = conn + .prepare( + "SELECT t.name FROM tags t JOIN piece_tags pt ON pt.tag_id = t.id + GROUP BY t.id ORDER BY COUNT(*) DESC LIMIT 5", + )? + .query_map([], |r| r.get::<_, String>(0))? + .collect::, _>>()?; + let total: i64 = conn + .query_row("SELECT COUNT(*) FROM pieces", [], |r| r.get(0))?; + (recent, top, least, tags, total) + }; + + // 4) 构造 system prompt + user prompt + let mut sys = String::from( + "你是音乐推荐助手。基于用户曲库画像和今天的网络热点,推荐 3-5 首具体曲目。\n\ + 规则:\n\ + - 必须推 (歌名 - 歌手) 二元组,不要含糊\n\ + - 一半推用户曲库里冷门或久未碰的('补回来'型,标 [📚])\n\ + - 一半推网络热点 / 用户没的('试试新'型,标 [✨])\n\ + - 每首一句话理由,要具体(说为啥适合现在 / 关联用户偏好 / 编曲特色)\n\ + - 不要重复每次的开场白;直奔主题;中文回答\n\ + - markdown,每首一行:- **歌名 - 歌手** [📚或✨] 理由", + ); + sys.push_str(&format!("\n\n(用户曲库共 {n_total} 首;标签偏好:{})", tags_top.join("、"))); + + let mut user_msg = String::new(); + user_msg.push_str(&format!("今天关键词:{query}\n\n")); + if !web_results.is_empty() { + user_msg.push_str("网络热点(前 5 条):\n"); + for r in &web_results { + let t = r.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let c = r.get("content").and_then(|v| v.as_str()).unwrap_or(""); + user_msg.push_str(&format!("- {} :: {}\n", t.trim(), c.trim().chars().take(180).collect::())); + } + user_msg.push('\n'); + } + user_msg.push_str("用户曲库画像:\n"); + user_msg.push_str(&format!("- 最近常练:{}\n", fmt_pieces(&recent))); + user_msg.push_str(&format!("- 最爱回听:{}\n", fmt_pieces(&top_played))); + user_msg.push_str(&format!("- 收藏但久没碰:{}\n", fmt_pieces(&least_played))); + user_msg.push_str(&format!( + "\n现在 {} 时刻。给我 4 首歌('补回来' 2 + '试试新' 2 推荐),开门见山。", + chrono_like(now_ts), + )); + + // 5) 用 OpenAI 兼容 stream 调 gemma + let payload = json!({ + "model": s.chat_model, + "messages": [ + { "role": "system", "content": sys }, + { "role": "user", "content": user_msg }, + ], + "stream": true, + "temperature": 1.0, + "top_p": 0.95, + }); + let url = format!("{}/chat/completions", s.chat_gateway.trim_end_matches('/')); + let req = s.http.post(&url).bearer_auth(&s.chat_token).json(&payload); + + let (tx, rx) = tokio::sync::mpsc::channel::>(64); + tokio::spawn(async move { + match req.send().await { + Ok(resp) if resp.status().is_success() => { + use futures::StreamExt; + let mut stream = resp.bytes_stream(); + let mut buf = String::new(); + while let Some(chunk) = stream.next().await { + let chunk = match chunk { + Ok(b) => b, + Err(e) => { + let _ = tx.send(Ok(Event::default().event("error").data(format!("stream: {e}")))).await; + break; + } + }; + buf.push_str(&String::from_utf8_lossy(&chunk)); + while let Some(idx) = buf.find('\n') { + let line = buf[..idx].trim().to_string(); + buf.drain(..=idx); + let Some(payload) = line.strip_prefix("data:") else { continue }; + let payload = payload.trim(); + if payload.is_empty() { continue; } + if payload == "[DONE]" { break; } + if let Ok(v) = serde_json::from_str::(payload) { + if let Some(delta) = v.get("choices").and_then(|c| c.get(0)) + .and_then(|c| c.get("delta")).and_then(|d| d.get("content")) + .and_then(|c| c.as_str()) + { + if !delta.is_empty() && tx.send(Ok(Event::default().data(delta.to_string()))).await.is_err() { + return; + } + } + } + } + } + } + Ok(resp) => { + let st = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let _ = tx.send(Ok(Event::default().event("error").data(format!("gateway {st}: {body}")))).await; + } + Err(e) => { + let _ = tx.send(Ok(Event::default().event("error").data(format!("connect: {e}")))).await; + } + } + let _ = tx.send(Ok(Event::default().event("done").data(""))).await; + }); + + Ok(Sse::new(tokio_stream::wrappers::ReceiverStream::new(rx)) + .keep_alive(axum::response::sse::KeepAlive::default())) +} + +fn fmt_pieces(items: &[PieceHint]) -> String { + if items.is_empty() { + return "(无)".to_string(); + } + items + .iter() + .map(|p| { + let a = if p.artist.is_empty() { + String::new() + } else { + format!(" - {}", p.artist) + }; + format!("《{}》{}({}次)", p.title, a, p.play_count) + }) + .collect::>() + .join("、") +} + +fn chrono_like(now_ms: u128) -> String { + // 不引 chrono,简化展示,让 gemma 知道是新一轮 + 大致时间段 + let secs = (now_ms / 1000) as i64; + let h = ((secs / 3600) % 24 + 8) % 24; // CST 偏移大致用,准不准无所谓 + let bucket = match h { + 5..=10 => "清晨", + 11..=13 => "中午", + 14..=17 => "下午", + 18..=21 => "晚上", + _ => "深夜", + }; + format!("{}(unix {})", bucket, now_ms) +} + // ---------- handlers: chord auto-fetch ---------- #[derive(Deserialize)]