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 @@
+
+
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)]