- 后端 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:
@@ -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() {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
placeholder="搜索曲目 / 歌手"
|
||||
/>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
@@ -258,6 +259,32 @@
|
||||
</template>
|
||||
</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 -->
|
||||
<div
|
||||
v-if="fullscreenSrc"
|
||||
@@ -330,6 +357,7 @@ import {
|
||||
listChat,
|
||||
clearChat,
|
||||
streamChat,
|
||||
streamInspire,
|
||||
} from '../lib/api.js'
|
||||
import { parseLrc } from '../lib/lrc.js'
|
||||
|
||||
@@ -397,6 +425,61 @@ let lastReportedId = null
|
||||
// fullscreen 乐谱
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// **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
|
||||
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%;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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