- 后端 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:
@@ -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<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 ----------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user