music(inspire): 加「💡 今天练什么」灵感推荐 modal
deploy music / build-and-deploy (push) Failing after 1m50s

- 后端 POST /api/inspire 流式 SSE:随机 keyword 池(23 个)+ 用户曲库画像(recent/top/least)+ Tavily 热点搜索 → gemma stream(temperature=1.0)
- Tavily key 走 k8s Secret tavily-creds(复用 mochi config 同一 token)
- 每次按按钮:keyword 随机 + 用户可输 hint("想练快歌" / "陪儿子" / "新东西")
- 输出强制格式:4 首歌('补回来' 2 + '试试新' 2),每首歌名-歌手 + 一句理由
- 前端 topbar 加 💡 按钮,modal 流式渲染(极简 md:**bold** + 列表)
This commit is contained in:
Fam Zheng
2026-05-10 15:52:00 +01:00
parent f7fac352a5
commit ccb5ad05ce
4 changed files with 512 additions and 0 deletions
+41
View File
@@ -133,6 +133,47 @@ export async function streamChat(pieceId, message, onDelta, signal) {
return { ok: true } 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 ---- // ---- tags ----
export function listTags() { export function listTags() {
@@ -9,6 +9,7 @@
placeholder="搜索曲目 / 歌手" placeholder="搜索曲目 / 歌手"
/> />
<span class="count">{{ filtered.length }} / {{ pieces.length }} </span> <span class="count">{{ filtered.length }} / {{ pieces.length }} </span>
<button class="btn-inspire" title="今天练什么?" @click="openInspire">💡</button>
<router-link to="/upload" class="btn-add" title="新增曲目"></router-link> <router-link to="/upload" class="btn-add" title="新增曲目"></router-link>
</header> </header>
@@ -258,6 +259,32 @@
</template> </template>
</section> </section>
<!-- 灵感 modal -->
<div v-if="inspireOpen" class="ins-overlay" @click.self="closeInspire">
<div class="ins-modal">
<header class="ins-head">
<span>💡 今天练什么</span>
<button class="ins-close" @click="closeInspire"></button>
</header>
<div class="ins-hint-row">
<input
v-model="inspireHint"
class="ins-hint"
:disabled="inspireRunning"
placeholder="可选:心情/目标("想轻松点" / "陪儿子" / "学新东西""
@keydown.enter.prevent="runInspire"
/>
<button
class="ins-go"
:disabled="inspireRunning"
@click="runInspire"
>{{ inspireRunning ? '⏳ 生成中…' : '换一批' }}</button>
</div>
<div class="ins-body" v-html="inspireHtml"></div>
<p v-if="inspireError" class="ins-err">{{ inspireError }}</p>
</div>
</div>
<!-- 全屏乐谱 overlay再点一下关闭或按 ESC --> <!-- 全屏乐谱 overlay再点一下关闭或按 ESC -->
<div <div
v-if="fullscreenSrc" v-if="fullscreenSrc"
@@ -330,6 +357,7 @@ import {
listChat, listChat,
clearChat, clearChat,
streamChat, streamChat,
streamInspire,
} from '../lib/api.js' } from '../lib/api.js'
import { parseLrc } from '../lib/lrc.js' import { parseLrc } from '../lib/lrc.js'
@@ -397,6 +425,61 @@ let lastReportedId = null
// fullscreen 乐谱 // fullscreen 乐谱
const fullscreenSrc = ref(null) const fullscreenSrc = ref(null)
// 灵感 modal
const inspireOpen = ref(false)
const inspireHint = ref('')
const inspireText = ref('')
const inspireRunning = ref(false)
const inspireError = ref('')
let inspireAbort = null
const inspireHtml = computed(() => mdLite(inspireText.value))
// 极简 markdown**粗体** + 列表 + 换行 → html
function mdLite(s) {
if (!s) return '<p class="ins-empty">点「换一批」让 LLM 给你推几首</p>'
// 转义 html,保留我们后面要插的 tag
let h = s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// **bold**
h = h.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
// 行首 - / * 列表
h = h.replace(/(^|\n)\s*[-*]\s+/g, '$1• ')
// 段落间双换行 → <p>
return h.split(/\n{2,}/).map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).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 // chord —— 两个 mode 各自独立 state
const chordStates = ref({ letters: 'idle', functional: 'idle' }) const chordStates = ref({ letters: 'idle', functional: 'idle' })
const chordErrors = ref({ letters: '', functional: '' }) const chordErrors = ref({ letters: '', functional: '' })
@@ -954,6 +1037,104 @@ onBeforeUnmount(() => {
} }
.topbar .search:focus { border-color: var(--accent-strong); } .topbar .search:focus { border-color: var(--accent-strong); }
.topbar .count { color: var(--text-mute); font-size: 12px; white-space: nowrap; } .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 { .topbar .btn-add {
width: 36px; height: 36px; width: 36px; height: 36px;
border-radius: 50%; border-radius: 50%;
+5
View File
@@ -62,6 +62,11 @@ spec:
secretKeyRef: secretKeyRef:
name: chat-creds name: chat-creds
key: token key: token
- name: TAVILY_TOKEN
valueFrom:
secretKeyRef:
name: tavily-creds
key: token
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /healthz path: /healthz
+285
View File
@@ -49,6 +49,8 @@ struct AppState {
chat_gateway: String, chat_gateway: String,
chat_token: String, chat_token: String,
chat_model: String, chat_model: String,
/// Tavily 网络搜索 token(给灵感推荐 endpoint 用)。
tavily_token: String,
} }
#[tokio::main] #[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_token = std::env::var("CHAT_TOKEN").unwrap_or_default();
let chat_model = let chat_model =
std::env::var("CHAT_MODEL").unwrap_or_else(|_| "gemma-4-31b-it".into()); 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 必须能跑很久。 // 关键:reqwest 默认 timeout 不要给 chat 用 —— chat stream 必须能跑很久。
// 对 chord sidecar 的小请求另外用 .timeout() per-request。 // 对 chord sidecar 的小请求另外用 .timeout() per-request。
let http = reqwest::Client::builder() let http = reqwest::Client::builder()
@@ -156,6 +159,7 @@ async fn main() -> std::io::Result<()> {
chat_gateway, chat_gateway,
chat_token, chat_token,
chat_model, chat_model,
tavily_token,
}; };
let api = Router::new() let api = Router::new()
@@ -171,6 +175,7 @@ async fn main() -> std::io::Result<()> {
"/pieces/:id/chat", "/pieces/:id/chat",
get(list_chat).post(post_chat).delete(clear_chat), get(list_chat).post(post_chat).delete(clear_chat),
) )
.route("/inspire", post(post_inspire))
.route( .route(
"/pieces/:id/attachments", "/pieces/:id/attachments",
post(upload_attachments).layer(DefaultBodyLimit::max(REQUEST_BYTES)), post(upload_attachments).layer(DefaultBodyLimit::max(REQUEST_BYTES)),
@@ -1080,6 +1085,286 @@ async fn playlist_remove_piece(
Ok(JsonResp(json!({ "ok": true }))) 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<String>,
}
#[derive(Serialize)]
struct PieceHint {
title: String,
artist: String,
play_count: i64,
}
async fn post_inspire(
State(s): State<AppState>,
JsonResp(body): JsonResp<InspireBody>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, 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<PieceHint> = 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::<Result<Vec<_>, _>>()?;
let top: Vec<PieceHint> = 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::<Result<Vec<_>, _>>()?;
let least: Vec<PieceHint> = 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::<Result<Vec<_>, _>>()?;
let tags: Vec<String> = 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::<Result<Vec<_>, _>>()?;
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::<String>()));
}
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::<Result<Event, Infallible>>(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::<Value>(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::<Vec<_>>()
.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 ---------- // ---------- handlers: chord auto-fetch ----------
#[derive(Deserialize)] #[derive(Deserialize)]