From c0d6e3732501eccbd942ffe1d7b8babc7e244d7b Mon Sep 17 00:00:00 2001 From: Fam Zheng Date: Sun, 10 May 2026 14:51:53 +0100 Subject: [PATCH] =?UTF-8?q?music:=20=E5=8A=A0=20LLM=20chat=E3=80=81?= =?UTF-8?q?=E7=AC=94=E8=AE=B0=20tab=20=E5=8C=96=E3=80=81=E6=AD=8C=E5=8D=95?= =?UTF-8?q?/=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat(右边栏): - chat_messages 表 per piece,OpenAI 兼容 /v1/chat/completions stream:true - backend SSE forward delta,结束时落库 user + assistant - system prompt 注入曲目 (title/artist/category/notes/lyrics 截 4KB) - 网关同 mochi/config.yaml: gemma-4-31b-it on 3.135.65.204:8848,token 走 k8s Secret chat-creds - reqwest client 去掉全局 timeout(chat 流可能跑很久),chord sidecar 调用改 per-request timeout 笔记: 从右 sidebar 移到独立 tab "笔记" 歌单 + tag: - playlists / playlist_pieces / tags / piece_tags 表,CRUD API - PATCH piece 接 tags 数组(按名字 upsert) - list pieces 加 ?tag/?playlist 过滤 + 返回 tags 列表 - 顶 bar filterbar:歌单 + 标签 chip 切换;"+ 新歌单" prompt 创建 - EditView 加 tag 编辑(chip + 自动补全)+ 加入/移除歌单 --- Cargo.lock | 78 +++ Cargo.toml | 4 +- apps/music/Cargo.toml | 2 + apps/music/frontend/src/lib/api.js | 131 +++- apps/music/frontend/src/views/EditView.vue | 162 +++++ apps/music/frontend/src/views/PlayerView.vue | 500 ++++++++++++-- apps/music/k8s/all.yaml | 9 + apps/music/src/main.rs | 679 ++++++++++++++++++- 8 files changed, 1480 insertions(+), 85 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e21f73..780a699 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,6 +218,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -225,6 +240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -233,6 +249,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -251,8 +295,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -671,11 +720,13 @@ version = "0.1.0" dependencies = [ "axum", "cube-core", + "futures", "reqwest", "rusqlite", "serde", "serde_json", "tokio", + "tokio-stream", "tower", "tower-http", "tracing", @@ -898,6 +949,7 @@ dependencies = [ "base64", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -917,12 +969,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", ] @@ -1281,6 +1335,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1564,6 +1629,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.98" diff --git a/Cargo.toml b/Cargo.toml index 4e7e540..02a935a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.32", features = ["bundled"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +futures = "0.3" +tokio-stream = "0.1" [profile.release] opt-level = "z" diff --git a/apps/music/Cargo.toml b/apps/music/Cargo.toml index 7a34143..9e8eea1 100644 --- a/apps/music/Cargo.toml +++ b/apps/music/Cargo.toml @@ -17,3 +17,5 @@ serde = { workspace = true } serde_json = { workspace = true } rusqlite = { workspace = true } reqwest = { workspace = true } +futures = { workspace = true } +tokio-stream = { workspace = true } diff --git a/apps/music/frontend/src/lib/api.js b/apps/music/frontend/src/lib/api.js index cf9a710..0327969 100644 --- a/apps/music/frontend/src/lib/api.js +++ b/apps/music/frontend/src/lib/api.js @@ -8,8 +8,12 @@ async function jsonOrThrow(res) { return res.json() } -export function listPieces() { - return fetch('/api/pieces').then(jsonOrThrow) +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) { @@ -65,3 +69,126 @@ export function chordFetch(pieceId) { export function chordStatus(pieceId) { return fetch(`/api/pieces/${pieceId}/chord/status`).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 } +} + +// ---- 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) +} diff --git a/apps/music/frontend/src/views/EditView.vue b/apps/music/frontend/src/views/EditView.vue index a4864dd..559a3f2 100644 --- a/apps/music/frontend/src/views/EditView.vue +++ b/apps/music/frontend/src/views/EditView.vue @@ -41,6 +41,26 @@ 笔记