// 薄薄一层 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) }