Files
cube/apps/music/frontend/src/lib/api.js
T
Fam Zheng ccb5ad05ce
deploy music / build-and-deploy (push) Failing after 1m50s
music(inspire): 加「💡 今天练什么」灵感推荐 modal
- 后端 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** + 列表)
2026-05-10 15:52:00 +01:00

236 lines
6.8 KiB
JavaScript

// 薄薄一层 fetch 封装。错误统一抛 Error(message)。
async function jsonOrThrow(res) {
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `${res.status} ${res.statusText}`)
}
return res.json()
}
export function listPieces({ tag, playlist } = {}) {
const qs = new URLSearchParams()
if (tag) qs.set('tag', tag)
if (playlist) qs.set('playlist', String(playlist))
const q = qs.toString()
return fetch('/api/pieces' + (q ? '?' + q : '')).then(jsonOrThrow)
}
export function getPiece(id) {
return fetch(`/api/pieces/${id}`).then(jsonOrThrow)
}
export function createPiece(body) {
return fetch('/api/pieces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(jsonOrThrow)
}
export function patchPiece(id, body) {
return fetch(`/api/pieces/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(jsonOrThrow)
}
export function deletePiece(id) {
return fetch(`/api/pieces/${id}`, { method: 'DELETE' }).then(jsonOrThrow)
}
export function recordPlay(id) {
return fetch(`/api/pieces/${id}/play`, { method: 'POST' }).then(jsonOrThrow)
}
// `role`: null | 'chord' | 'numbered' | 'staff'
export function uploadAttachments(pieceId, files, role) {
const fd = new FormData()
for (const f of files) fd.append('files', f, f.name)
const url = role
? `/api/pieces/${pieceId}/attachments?role=${encodeURIComponent(role)}`
: `/api/pieces/${pieceId}/attachments`
return fetch(url, { method: 'POST', body: fd }).then(jsonOrThrow)
}
export function deleteAttachment(id) {
return fetch(`/api/attachments/${id}`, { method: 'DELETE' }).then(jsonOrThrow)
}
export function attachmentUrl(id) {
return `/api/attachments/${id}`
}
export function chordFetch(pieceId, mode = 'functional') {
return fetch(`/api/pieces/${pieceId}/chord/fetch?mode=${encodeURIComponent(mode)}`, { method: 'POST' }).then(jsonOrThrow)
}
export function chordStatus(pieceId, mode = 'functional') {
return fetch(`/api/pieces/${pieceId}/chord/status?mode=${encodeURIComponent(mode)}`).then(jsonOrThrow)
}
// ---- chat ----
export function listChat(pieceId) {
return fetch(`/api/pieces/${pieceId}/chat`).then(jsonOrThrow)
}
export function clearChat(pieceId) {
return fetch(`/api/pieces/${pieceId}/chat`, { method: 'DELETE' }).then(jsonOrThrow)
}
/**
* stream a chat reply.
* @param {number} pieceId
* @param {string} message
* @param {(delta: string) => void} onDelta
* @param {AbortSignal} signal
* @returns {Promise<{ok: boolean, error?: string}>}
*/
export async function streamChat(pieceId, message, onDelta, signal) {
const resp = await fetch(`/api/pieces/${pieceId}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
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)
// SSE 协议:空行 = event 边界(已在内容中处理);event:/data: 各自一行
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') {
// 不带 data 也算结束
} else {
onDelta(data)
}
} else if (line === '') {
lastEvent = 'message'
}
}
}
if (errorMsg) return { ok: false, error: errorMsg }
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() {
return fetch('/api/tags').then(jsonOrThrow)
}
export function createTag(name) {
return fetch('/api/tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
}).then(jsonOrThrow)
}
export function deleteTag(id) {
return fetch(`/api/tags/${id}`, { method: 'DELETE' }).then(jsonOrThrow)
}
// ---- playlists ----
export function listPlaylists() {
return fetch('/api/playlists').then(jsonOrThrow)
}
export function createPlaylist(name, description) {
return fetch('/api/playlists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description: description || null }),
}).then(jsonOrThrow)
}
export function getPlaylist(id) {
return fetch(`/api/playlists/${id}`).then(jsonOrThrow)
}
export function patchPlaylist(id, body) {
return fetch(`/api/playlists/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(jsonOrThrow)
}
export function deletePlaylist(id) {
return fetch(`/api/playlists/${id}`, { method: 'DELETE' }).then(jsonOrThrow)
}
export function playlistAddPiece(playlistId, pieceId) {
return fetch(`/api/playlists/${playlistId}/pieces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ piece_id: pieceId }),
}).then(jsonOrThrow)
}
export function playlistRemovePiece(playlistId, pieceId) {
return fetch(`/api/playlists/${playlistId}/pieces/${pieceId}`, { method: 'DELETE' }).then(jsonOrThrow)
}