music: 加 LLM chat、笔记 tab 化、歌单/标签
deploy cube / build-and-deploy (push) Successful in 1m8s
deploy music / build-and-deploy (push) Successful in 2m15s
deploy simpleasm / build-and-deploy (push) Successful in 1m25s

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 + 自动补全)+ 加入/移除歌单
This commit is contained in:
Fam Zheng
2026-05-10 14:51:53 +01:00
parent 9623e298b7
commit c0d6e37325
8 changed files with 1480 additions and 85 deletions
Generated
+78
View File
@@ -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"
+3 -1
View File
@@ -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"
+2
View File
@@ -17,3 +17,5 @@ serde = { workspace = true }
serde_json = { workspace = true }
rusqlite = { workspace = true }
reqwest = { workspace = true }
futures = { workspace = true }
tokio-stream = { workspace = true }
+129 -2
View File
@@ -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)
}
+162
View File
@@ -41,6 +41,26 @@
<span>笔记</span>
<textarea v-model="form.notes" rows="3" />
</label>
<label class="field">
<span>标签</span>
<div class="tag-row">
<span v-for="t in tags" :key="t" class="tag-chip">
#{{ t }}
<button class="tag-x" @click="removeTag(t)">×</button>
</span>
<input
v-model="tagInput"
@keydown.enter.prevent="addTag"
@blur="addTag"
list="tag-suggest"
class="tag-input"
placeholder="输入 + 回车(流行/钢琴/吉他/卡拉OK/英文…)"
/>
<datalist id="tag-suggest">
<option v-for="t in allTags" :key="t.id" :value="t.name" />
</datalist>
</div>
</label>
<div class="actions">
<button class="btn-primary" :disabled="savingMeta" @click="saveMeta">
{{ savingMeta ? '保存中' : '保存基本信息' }}
@@ -49,6 +69,32 @@
</div>
</section>
<section class="block">
<h2>歌单</h2>
<p v-if="memberPlaylists.length === 0" class="hint-sub">这首歌还没在任何歌单里</p>
<div v-else class="pl-row">
<span
v-for="plId in memberPlaylists"
:key="plId"
class="tag-chip"
>
{{ (playlists.find(x => x.id === plId) || {}).name || ('#' + plId) }}
<button class="tag-x" @click="leavePlaylist(plId)">×</button>
</span>
</div>
<div class="join-row">
<select v-model="addToPlaylistId">
<option value=""> 选歌单 </option>
<option
v-for="pl in playlists.filter(p => !memberPlaylists.includes(p.id))"
:key="pl.id"
:value="pl.id"
>{{ pl.name }} ({{ pl.count }})</option>
</select>
<button class="btn-ghost" :disabled="!addToPlaylistId" @click="joinPlaylist">加入</button>
</div>
</section>
<section class="block">
<h2>添加附件</h2>
<p class="hint-sub">
@@ -119,6 +165,11 @@ import {
deletePiece,
uploadAttachments,
deleteAttachment,
listTags,
listPlaylists,
playlistAddPiece,
playlistRemovePiece,
getPlaylist,
} from '../lib/api.js'
const props = defineProps({ id: { type: Number, required: true } })
@@ -138,6 +189,14 @@ const form = reactive({
const savingMeta = ref(false)
const savedFlash = ref(false)
const tags = ref([]) // 当前 piece 的 tags 数组
const allTags = ref([]) // 全局所有 tags(用于 datalist 提示)
const tagInput = ref('')
const playlists = ref([]) // 全部 playlists
const memberPlaylists = ref([]) // 当前 piece 在哪些 playlists 里(id 集合)
const addToPlaylistId = ref('')
const fileInputEl = ref(null)
const pendingFiles = ref([])
const uploadRole = ref(null)
@@ -156,6 +215,20 @@ async function load() {
form.category = p.category || ''
form.lyrics = p.lyrics || ''
form.notes = p.notes || ''
tags.value = [...(p.tags || [])]
// 全局 tag 列表 + 我所属的 playlists
const [allT, allPl] = await Promise.all([listTags(), listPlaylists()])
allTags.value = allT || []
playlists.value = allPl || []
// 反查所属:每个 playlist 拿 detail,看 pieces 包含 props.id 否
const memberships = await Promise.all(
(allPl || []).map(pl =>
getPlaylist(pl.id)
.then(d => (d.pieces || []).some(x => x.id === props.id) ? pl.id : null)
.catch(() => null)
)
)
memberPlaylists.value = memberships.filter(x => x != null)
} catch (e) {
loadErr.value = e.message || String(e)
} finally {
@@ -163,6 +236,39 @@ async function load() {
}
}
function addTag() {
const v = tagInput.value.trim()
if (!v) return
if (!tags.value.includes(v)) tags.value.push(v)
tagInput.value = ''
}
function removeTag(t) {
tags.value = tags.value.filter(x => x !== t)
}
async function joinPlaylist() {
const pid = parseInt(addToPlaylistId.value)
if (!pid) return
try {
await playlistAddPiece(pid, props.id)
addToPlaylistId.value = ''
await load()
} catch (e) {
alert(e.message || String(e))
}
}
async function leavePlaylist(plId) {
if (!confirm('从这个歌单移除?')) return
try {
await playlistRemovePiece(plId, props.id)
await load()
} catch (e) {
alert(e.message || String(e))
}
}
async function saveMeta() {
savingMeta.value = true
try {
@@ -172,6 +278,7 @@ async function saveMeta() {
category: form.category.trim() || null,
lyrics: form.lyrics || null,
notes: form.notes || null,
tags: tags.value,
})
savedFlash.value = true
setTimeout(() => (savedFlash.value = false), 1500)
@@ -393,4 +500,59 @@ onMounted(load)
.danger-block { background: rgba(239, 68, 68, 0.05); }
.danger-block .btn-danger { padding: 8px 18px; font-size: 13px; }
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(192, 132, 252, 0.12);
color: var(--accent);
border-radius: 12px;
padding: 2px 4px 2px 10px;
font-size: 12px;
}
.tag-x {
background: none;
color: var(--accent);
font-size: 14px;
width: 18px;
height: 18px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tag-x:hover { background: rgba(192, 132, 252, 0.2); }
.tag-input {
flex: 1;
min-width: 200px;
background: transparent;
border: none;
font-size: 13px;
color: var(--text);
padding: 4px 6px;
}
.tag-input:focus { outline: none; }
.pl-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.join-row { display: flex; gap: 8px; align-items: center; }
.join-row select {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
color: var(--text);
min-width: 220px;
}
</style>
+428 -66
View File
@@ -12,6 +12,38 @@
<router-link to="/upload" class="btn-add" title="新增曲目"></router-link>
</header>
<nav class="filterbar">
<span class="fb-label">歌单</span>
<button
class="chip"
:class="{ active: !activePlaylistId }"
@click="setPlaylist(null)"
>全部</button>
<button
v-for="pl in playlists"
:key="pl.id"
class="chip"
:class="{ active: activePlaylistId === pl.id }"
@click="setPlaylist(pl.id)"
>{{ pl.name }}<span class="chip-n">{{ pl.count }}</span></button>
<button class="chip new-chip" @click="promptNewPlaylist"> 新歌单</button>
<span class="fb-sep">·</span>
<span class="fb-label">标签</span>
<button
class="chip"
:class="{ active: !activeTagName }"
@click="setTag(null)"
>全部</button>
<button
v-for="t in tagsList"
:key="t.id"
class="chip"
:class="{ active: activeTagName === t.name }"
@click="setTag(t.name)"
>{{ t.name }}<span class="chip-n">{{ t.count }}</span></button>
</nav>
<div class="main">
<aside class="sidebar" :class="{ 'has-selected': !!selected }">
<div class="sort-bar">
@@ -26,7 +58,7 @@
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="loadError" class="hint err">{{ loadError }}</p>
<p v-else-if="filtered.length === 0" class="hint">
空空如也<router-link to="/upload">先加一首</router-link>
空空如也<span v-if="activePlaylistId || activeTagName">当前筛选下</span>
</p>
<div
v-for="p in filtered"
@@ -40,6 +72,7 @@
<div class="row-meta">
<span v-if="p.artist">{{ p.artist }}</span>
<span v-if="p.category" class="cat">{{ p.category }}</span>
<span v-for="tg in p.tags" :key="tg" class="tg">#{{ tg }}</span>
</div>
</div>
<div class="badges">
@@ -65,6 +98,9 @@
<span v-if="selected.play_count">· 播放 {{ selected.play_count }} </span>
<router-link :to="{ name: 'edit', params: { id: selected.id } }" class="edit-link">编辑</router-link>
</div>
<div v-if="selected.tags && selected.tags.length" class="np-tags">
<span v-for="tg in selected.tags" :key="tg" class="tg">#{{ tg }}</span>
</div>
</header>
<nav v-if="tabs.length" class="tabs">
@@ -94,7 +130,7 @@
>{{ line.text }}</div>
</div>
<!-- 谱面chord / numbered / staff -->
<!-- 谱面 -->
<div v-show="['chord', 'numbered', 'staff'].includes(activeTab)" class="sheet-box">
<img
v-for="att in roleAttachments(activeTab)"
@@ -103,7 +139,6 @@
:alt="att.filename"
class="sheet-img"
/>
<!-- 吉他谱专属没图时给个自动抓取按钮 -->
<div
v-if="activeTab === 'chord' && roleAttachments('chord').length === 0"
class="auto-fetch"
@@ -149,6 +184,20 @@
class="video-el"
/>
</div>
<!-- 笔记独立 tab -->
<div v-show="activeTab === 'notes'" class="notes-box">
<div class="notes-head">
<span>练琴心得 / 难点 / 备注</span>
<span v-if="notesSavedFlash" class="saved">已保存</span>
</div>
<textarea
v-model="notesDraft"
@input="onNotesInput"
class="notes-area"
placeholder="自动保存。任何想法都丢这里…"
/>
</div>
</main>
<footer class="controls">
@@ -181,16 +230,47 @@
</template>
</section>
<aside v-if="selected" class="notes" :class="{ active: notesOpen }">
<header @click="notesOpen = !notesOpen">
<span>笔记</span>
<span v-if="notesSavedFlash" class="saved">已保存</span>
<!-- 右侧 LLM chat 边栏 -->
<aside v-if="selected" class="chat">
<header class="chat-head">
<span>麻薯 · {{ selected.title.slice(0, 12) }}</span>
<button class="chat-clear" @click="onChatClear" title="清空对话">清空</button>
</header>
<div ref="chatBodyEl" class="chat-body">
<p v-if="chatLoading" class="chat-empty">载入对话</p>
<p v-else-if="chatMessages.length === 0 && !chatStreaming" class="chat-empty">
随便聊点啥比如<br>
这首歌的副歌为啥用 6m <br>
我吉他扫弦节奏总不稳怎么办
</p>
<div
v-for="(m, i) in chatMessages"
:key="i"
class="msg"
:class="m.role"
>
<div class="msg-bubble">{{ m.content }}</div>
</div>
<div v-if="chatStreaming || chatStreamText" class="msg assistant">
<div class="msg-bubble">{{ chatStreamText || '…' }}</div>
</div>
<p v-if="chatError" class="chat-err">{{ chatError }}</p>
</div>
<div class="chat-input">
<textarea
v-model="notesDraft"
@input="onNotesInput"
placeholder="练琴心得 / chord 备注 / 难点…"
v-model="chatDraft"
@keydown.enter.exact.prevent="onChatSend"
@keydown.enter.shift.exact="$event => null"
:disabled="chatStreaming"
placeholder="Enter 发送 · Shift+Enter 换行"
rows="2"
/>
<button
class="chat-send"
:disabled="chatStreaming || !chatDraft.trim()"
@click="onChatSend"
>{{ chatStreaming ? '⏳' : '↑' }}</button>
</div>
</aside>
</div>
</div>
@@ -201,12 +281,18 @@ import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
listPieces,
listPlaylists,
listTags,
createPlaylist,
getPiece,
patchPiece,
recordPlay,
attachmentUrl as attUrl,
chordFetch,
chordStatus,
listChat,
clearChat,
streamChat,
} from '../lib/api.js'
import { parseLrc } from '../lib/lrc.js'
@@ -219,6 +305,11 @@ const loadError = ref('')
const selected = ref(null)
const selectedId = ref(null)
const playlists = ref([])
const tagsList = ref([])
const activePlaylistId = ref(null)
const activeTagName = ref(null)
const search = ref('')
const sortMode = ref(localStorage.getItem('music.sort') || 'name')
const repeatOne = ref(false)
@@ -229,19 +320,28 @@ const playing = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const activeTab = ref('lyrics')
const notesOpen = ref(false)
const notesDraft = ref('')
const notesSavedFlash = ref(false)
let notesTimer = null
let randomSeed = Math.random()
let lastReportedId = null
// chord auto-fetch state
const chordState = ref('idle') // idle | pending | processing | completed | failed
// chord
const chordState = ref('idle')
const chordError = ref('')
let chordPollTimer = null
let chordPollStarted = 0
// chat
const chatBodyEl = ref(null)
const chatMessages = ref([])
const chatLoading = ref(false)
const chatDraft = ref('')
const chatStreaming = ref(false)
const chatStreamText = ref('')
const chatError = ref('')
let chatAbort = null
const lyricsLines = computed(() => parseLrc(selected.value?.lyrics || ''))
const activeLyricIdx = computed(() => {
@@ -271,7 +371,6 @@ const tabs = computed(() => {
if (!selected.value) return []
const list = []
if (selected.value.lyrics) list.push({ key: 'lyrics', label: '歌词', count: 0 })
// 吉他谱 tab 永远给(没图时显示自动抓取按钮)
list.push({ key: 'chord', label: '吉他谱', count: roleAttachments('chord').length })
const num = roleAttachments('numbered').length
if (num) list.push({ key: 'numbered', label: '简谱', count: num })
@@ -279,6 +378,7 @@ const tabs = computed(() => {
if (staff) list.push({ key: 'staff', label: '五线谱', count: staff })
if (pdfAttachments.value.length) list.push({ key: 'pdf', label: '乐谱 PDF', count: pdfAttachments.value.length })
if (videoAttachments.value.length) list.push({ key: 'video', label: '视频', count: videoAttachments.value.length })
list.push({ key: 'notes', label: '笔记', count: 0 })
return list
})
@@ -287,7 +387,7 @@ const filtered = computed(() => {
let arr = pieces.value
if (q) {
arr = arr.filter(p => {
const hay = `${p.title} ${p.artist || ''} ${p.category || ''}`.toLowerCase()
const hay = `${p.title} ${p.artist || ''} ${p.category || ''} ${(p.tags || []).join(' ')}`.toLowerCase()
return hay.includes(q)
})
}
@@ -307,8 +407,7 @@ const filtered = computed(() => {
})
break
case 'random': {
// stable random per session
const seeded = arr.map((p, i) => ({ p, k: hash(p.id, randomSeed) }))
const seeded = arr.map(p => ({ p, k: hash(p.id, randomSeed) }))
seeded.sort((a, b) => a.k - b.k)
arr = seeded.map(x => x.p)
break
@@ -320,7 +419,6 @@ const filtered = computed(() => {
})
function hash(id, seed) {
// 小随机 hashsort key 稳定
let x = (id ^ Math.floor(seed * 1e9)) >>> 0
x = (x ^ (x << 13)) >>> 0
x = (x ^ (x >>> 17)) >>> 0
@@ -337,7 +435,6 @@ function setSort(mode) {
}
function iconKinds(kinds) {
// 显示主要 kind 徽章;'image' / 'audio' / 'video' / 'pdf'
const order = ['audio', 'video', 'pdf', 'image']
return order.filter(k => kinds.includes(k))
}
@@ -350,7 +447,10 @@ async function loadPieces() {
loading.value = true
loadError.value = ''
try {
pieces.value = await listPieces()
pieces.value = await listPieces({
tag: activeTagName.value,
playlist: activePlaylistId.value,
})
} catch (e) {
loadError.value = e.message || String(e)
} finally {
@@ -358,25 +458,56 @@ async function loadPieces() {
}
}
async function loadPlaylists() {
try { playlists.value = await listPlaylists() } catch {}
}
async function loadTags() {
try { tagsList.value = await listTags() } catch {}
}
async function setPlaylist(id) {
activePlaylistId.value = id
if (id) activeTagName.value = null
await loadPieces()
}
async function setTag(name) {
activeTagName.value = name
if (name) activePlaylistId.value = null
await loadPieces()
}
async function promptNewPlaylist() {
const name = prompt('新歌单名(如:我喜欢的 / 儿子在练)')
if (!name || !name.trim()) return
try {
const r = await createPlaylist(name.trim())
await loadPlaylists()
setPlaylist(r.id)
} catch (e) {
alert(e.message || String(e))
}
}
async function loadPiece(id) {
selected.value = null
notesDraft.value = ''
// 切歌时清空 chord state(避免 polling 漂到新曲目)
stopChordPoll()
chordState.value = 'idle'
chordError.value = ''
abortChat()
chatMessages.value = []
chatStreamText.value = ''
chatError.value = ''
if (!id) return
try {
const p = await getPiece(id)
selected.value = p
notesDraft.value = p.notes || ''
selectedId.value = p.id
// 默认 tab:有歌词进 lyrics,否则进第一个 tab
const t = tabs.value
if (!t.find(x => x.key === activeTab.value)) {
activeTab.value = t[0]?.key || 'lyrics'
}
// 自动开播放(如果有 audio)
await nextTick()
const first = audioAttachments.value[0]
if (first && audioEl.value) {
@@ -387,18 +518,31 @@ async function loadPiece(id) {
audioEl.value.load()
}
lastReportedId = null
// 加载 chat 历史
loadChat(id)
} catch (e) {
loadError.value = e.message || String(e)
}
}
async function loadChat(id) {
chatLoading.value = true
try {
const arr = await listChat(id)
if (selectedId.value === id) chatMessages.value = arr || []
} catch (e) {
chatError.value = e.message || String(e)
} finally {
chatLoading.value = false
}
}
function selectPiece(id) {
router.push({ name: 'piece', params: { id } })
}
function attachmentUrl(id) { return attUrl(id) }
// player controls
function togglePlay() {
if (!audioEl.value || !audioEl.value.src) return
if (audioEl.value.paused) audioEl.value.play()
@@ -434,11 +578,9 @@ function seekBar(e) {
function onTimeUpdate(e) {
currentTime.value = e.target.currentTime
// 上报播放(≥10s 时)
if (selectedId.value && lastReportedId !== selectedId.value && currentTime.value >= 10) {
lastReportedId = selectedId.value
recordPlay(selectedId.value).then(d => {
// 同步本地 + list
if (selected.value) selected.value.play_count = d.play_count
const inList = pieces.value.find(p => p.id === selectedId.value)
if (inList) {
@@ -447,7 +589,6 @@ function onTimeUpdate(e) {
}
}).catch(() => {})
}
// 自动滚歌词
if (activeTab.value === 'lyrics' && lyricsBoxEl.value) {
const idx = activeLyricIdx.value
if (idx >= 0) {
@@ -455,7 +596,6 @@ function onTimeUpdate(e) {
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
// 持久化最近播放进度
if (selectedId.value) {
localStorage.setItem('music.last', JSON.stringify({
id: selectedId.value,
@@ -493,6 +633,20 @@ function setTab(k) {
activeTab.value = k
}
// notes auto-save
function onNotesInput() {
if (!selectedId.value) return
if (notesTimer) clearTimeout(notesTimer)
notesTimer = setTimeout(async () => {
try {
await patchPiece(selectedId.value, { notes: notesDraft.value || null })
notesSavedFlash.value = true
setTimeout(() => (notesSavedFlash.value = false), 1500)
} catch {}
}, 600)
}
// chord
async function startChordFetch() {
if (!selectedId.value) return
chordState.value = 'pending'
@@ -500,7 +654,6 @@ async function startChordFetch() {
try {
const r = await chordFetch(selectedId.value)
if (r.status === 'completed') {
// 已经有谱(或刚 import):刷新 piece
await reloadPiece()
chordState.value = 'completed'
return
@@ -517,11 +670,10 @@ async function startChordFetch() {
async function pollChord() {
if (!selectedId.value) { stopChordPoll(); return }
// 90s 超时保护
if (Date.now() - chordPollStarted > 90_000) {
stopChordPoll()
chordState.value = 'failed'
chordError.value = '抓取超时>90s),可能 yopu 限流或 selector 失效'
chordError.value = '抓取超时'
return
}
try {
@@ -535,7 +687,6 @@ async function pollChord() {
stopChordPoll()
}
} catch (e) {
// 暂时性错误就不立即放弃,下一轮再试
chordError.value = e.message || String(e)
}
}
@@ -551,22 +702,77 @@ async function reloadPiece() {
if (!selectedId.value) return
try {
const fresh = await getPiece(selectedId.value)
// 保留正在播的 audio.src 不动
selected.value = fresh
} catch {}
}
// notes auto-save
function onNotesInput() {
if (!selectedId.value) return
if (notesTimer) clearTimeout(notesTimer)
notesTimer = setTimeout(async () => {
// chat
async function onChatSend() {
const msg = chatDraft.value.trim()
if (!msg || chatStreaming.value || !selectedId.value) return
// 乐观追加 user msgassistant 流完整结束后会从 db 读最终 +1)
chatMessages.value = [
...chatMessages.value,
{ role: 'user', content: msg, id: Date.now() },
]
chatDraft.value = ''
chatStreamText.value = ''
chatError.value = ''
chatStreaming.value = true
await nextTick(); scrollChatBottom()
const ctrl = new AbortController()
chatAbort = ctrl
const pidAtStart = selectedId.value
try {
await patchPiece(selectedId.value, { notes: notesDraft.value || null })
notesSavedFlash.value = true
setTimeout(() => (notesSavedFlash.value = false), 1500)
} catch {}
}, 600)
const result = await streamChat(pidAtStart, msg, (delta) => {
if (selectedId.value !== pidAtStart) return
chatStreamText.value += delta
scrollChatBottom()
}, ctrl.signal)
if (selectedId.value !== pidAtStart) return
if (result.ok) {
chatMessages.value = [
...chatMessages.value,
{ role: 'assistant', content: chatStreamText.value, id: Date.now() + 1 },
]
} else {
chatError.value = result.error || '出错'
}
} catch (e) {
if (e.name !== 'AbortError') chatError.value = e.message || String(e)
} finally {
chatStreamText.value = ''
chatStreaming.value = false
chatAbort = null
await nextTick(); scrollChatBottom()
}
}
async function onChatClear() {
if (!selectedId.value || !confirm('清空这首歌的对话历史?')) return
abortChat()
try {
await clearChat(selectedId.value)
chatMessages.value = []
chatStreamText.value = ''
chatError.value = ''
} catch (e) {
alert(e.message || String(e))
}
}
function abortChat() {
if (chatAbort) {
try { chatAbort.abort() } catch {}
chatAbort = null
}
chatStreaming.value = false
chatStreamText.value = ''
}
function scrollChatBottom() {
const el = chatBodyEl.value
if (el) el.scrollTop = el.scrollHeight
}
// keyboard
@@ -600,13 +806,12 @@ watch(() => route.params.id, async (idStr) => {
onMounted(async () => {
document.addEventListener('keydown', onKeyDown)
await loadPieces()
await Promise.all([loadPieces(), loadPlaylists(), loadTags()])
const id = route.params.id ? Number(route.params.id) : null
if (id) {
selectedId.value = id
await loadPiece(id)
} else {
// 无路由 id:恢复 last
try {
const last = JSON.parse(localStorage.getItem('music.last') || 'null')
if (last && pieces.value.find(p => p.id === last.id)) {
@@ -620,6 +825,7 @@ onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
if (notesTimer) clearTimeout(notesTimer)
stopChordPoll()
abortChat()
})
</script>
@@ -660,10 +866,55 @@ onBeforeUnmount(() => {
font-size: 22px; font-weight: 600;
display: inline-flex; align-items: center; justify-content: center;
text-decoration: none;
transition: background 0.15s;
}
.topbar .btn-add:hover { background: var(--accent); text-decoration: none; }
.filterbar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border-soft);
flex-wrap: wrap;
flex-shrink: 0;
font-size: 12px;
}
.fb-label {
color: var(--text-mute);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 10px;
margin-right: 2px;
}
.fb-sep { color: var(--text-mute); margin: 0 4px; }
.chip {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text-dim);
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
transition: all 0.15s;
}
.chip:hover { color: var(--text); }
.chip.active {
background: var(--bg-active);
border-color: var(--accent-strong);
color: var(--accent);
}
.chip-n {
font-size: 10px;
color: var(--text-mute);
}
.new-chip {
border-style: dashed;
color: var(--text-mute);
}
.main {
flex: 1;
display: flex;
@@ -672,8 +923,8 @@ onBeforeUnmount(() => {
}
.sidebar {
width: 340px;
min-width: 280px;
width: 320px;
min-width: 260px;
border-right: 1px solid var(--border-soft);
display: flex;
flex-direction: column;
@@ -694,7 +945,6 @@ onBeforeUnmount(() => {
border: 1px solid var(--border);
background: var(--bg-elev);
border-right-width: 0;
transition: all 0.15s;
}
.sort-bar button:first-child { border-radius: 4px 0 0 4px; }
.sort-bar button:last-child { border-radius: 0 4px 4px 0; border-right-width: 1px; }
@@ -734,6 +984,7 @@ onBeforeUnmount(() => {
margin-top: 2px;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.row-meta .cat {
color: var(--accent-cyan);
@@ -741,6 +992,14 @@ onBeforeUnmount(() => {
padding: 0 6px;
border-radius: 8px;
}
.tg {
color: var(--accent);
background: rgba(192, 132, 252, 0.1);
padding: 0 6px;
border-radius: 8px;
font-size: 11px;
}
.np-tags { display: flex; gap: 6px; justify-content: center; margin-top: 4px; flex-wrap: wrap; }
.badges {
display: flex;
@@ -778,7 +1037,7 @@ onBeforeUnmount(() => {
}
.now-playing {
padding: 18px 24px 8px;
padding: 16px 24px 6px;
text-align: center;
flex-shrink: 0;
}
@@ -903,10 +1162,8 @@ onBeforeUnmount(() => {
border-radius: 8px;
font-size: 14px;
font-weight: 600;
transition: background 0.15s, transform 0.05s;
}
.btn-fetch:hover:not(:disabled) { background: var(--accent); }
.btn-fetch:active:not(:disabled) { transform: scale(0.97); }
.btn-fetch .spin { display: inline-block; animation: spin-anim 1.5s linear infinite; }
@keyframes spin-anim { to { transform: rotate(360deg); } }
@@ -928,6 +1185,35 @@ onBeforeUnmount(() => {
border-radius: 8px;
}
.notes-box {
display: flex;
flex-direction: column;
height: 100%;
gap: 8px;
}
.notes-head {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text-mute);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.notes-head .saved { color: var(--accent-green); }
.notes-area {
flex: 1;
min-height: 240px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
font-size: 14px;
line-height: 1.6;
resize: vertical;
}
.notes-area:focus { border-color: var(--accent-strong); }
.controls {
flex-shrink: 0;
background: var(--bg-card);
@@ -950,7 +1236,6 @@ onBeforeUnmount(() => {
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
}
.btn-icon:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.btn-icon.big {
@@ -1006,36 +1291,113 @@ onBeforeUnmount(() => {
text-align: center;
}
.notes {
width: 260px;
/* Chat sidebar */
.chat {
width: 320px;
border-left: 1px solid var(--border-soft);
background: var(--bg-elev);
display: none;
flex-direction: column;
flex-shrink: 0;
}
.notes header {
padding: 12px 16px;
@media (min-width: 1280px) {
.chat { display: flex; }
}
.chat-head {
padding: 10px 14px;
font-size: 12px;
color: var(--text-mute);
border-bottom: 1px solid var(--border-soft);
display: flex;
justify-content: space-between;
cursor: pointer;
align-items: center;
}
.notes header .saved { color: var(--accent-green); font-size: 10px; }
.notes textarea {
.chat-clear {
background: rgba(239, 68, 68, 0.1);
color: var(--accent-red);
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
}
.chat-clear:hover { background: rgba(239, 68, 68, 0.2); }
.chat-body {
flex: 1;
padding: 12px 14px;
overflow-y: auto;
padding: 12px 12px 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-empty {
color: var(--text-mute);
font-size: 13px;
line-height: 1.6;
text-align: center;
padding: 30px 8px;
line-height: 1.7;
}
.chat-err {
color: var(--accent-red);
font-size: 12px;
background: rgba(239, 68, 68, 0.08);
padding: 8px 10px;
border-radius: 6px;
}
.msg {
display: flex;
}
.msg.user { justify-content: flex-end; }
.msg.assistant { justify-content: flex-start; }
.msg-bubble {
max-width: 84%;
padding: 8px 12px;
border-radius: 12px;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.msg.user .msg-bubble {
background: var(--accent-strong);
color: #fff;
border-bottom-right-radius: 3px;
}
.msg.assistant .msg-bubble {
background: var(--bg-card);
color: var(--text);
border-bottom-left-radius: 3px;
}
.chat-input {
border-top: 1px solid var(--border-soft);
padding: 10px;
display: flex;
gap: 8px;
align-items: end;
}
.chat-input textarea {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
line-height: 1.5;
resize: none;
color: var(--text-dim);
color: var(--text);
}
.notes textarea::placeholder { color: var(--text-mute); }
@media (min-width: 1200px) {
.notes { display: flex; }
.chat-input textarea:focus { border-color: var(--accent-strong); outline: none; }
.chat-send {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent-strong);
color: #fff;
font-size: 16px;
font-weight: 600;
flex-shrink: 0;
}
.chat-send:hover:not(:disabled) { background: var(--accent); }
@media (max-width: 768px) {
.main { flex-direction: column; }
+9
View File
@@ -53,6 +53,15 @@ spec:
value: /data/blobs
- name: CHORD_URL
value: http://localhost:8001
- name: CHAT_GATEWAY
value: http://3.135.65.204:8848/v1
- name: CHAT_MODEL
value: gemma-4-31b-it
- name: CHAT_TOKEN
valueFrom:
secretKeyRef:
name: chat-creds
key: token
readinessProbe:
httpGet:
path: /healthz
+666 -13
View File
@@ -20,10 +20,15 @@ use axum::{
body::Body,
extract::{DefaultBodyLimit, Multipart, Path, Query, Request, State},
http::{header, StatusCode},
response::{IntoResponse, Json as JsonResp, Response},
routing::{get, post},
response::{
sse::{Event, Sse},
IntoResponse, Json as JsonResp, Response,
},
routing::{delete, get, post},
Router,
};
use futures::Stream;
use std::convert::Infallible;
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -40,6 +45,10 @@ struct AppState {
/// 同 pod 的 chord-fetcher sidecar root(默认 http://localhost:8001)。
chord_url: String,
http: reqwest::Client,
/// LLM 网关(OpenAI 兼容 /v1)—— 同 mochi/config.yaml。
chat_gateway: String,
chat_token: String,
chat_model: String,
}
#[tokio::main]
@@ -80,15 +89,62 @@ async fn main() -> std::io::Result<()> {
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (piece_id) REFERENCES pieces(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_att_piece ON attachments(piece_id);",
CREATE INDEX IF NOT EXISTS idx_att_piece ON attachments(piece_id);
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
piece_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (piece_id) REFERENCES pieces(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_chat_piece ON chat_messages(piece_id);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS piece_tags (
piece_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (piece_id, tag_id),
FOREIGN KEY (piece_id) REFERENCES pieces(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_pt_tag ON piece_tags(tag_id);
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS playlist_pieces (
playlist_id INTEGER NOT NULL,
piece_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (playlist_id, piece_id),
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
FOREIGN KEY (piece_id) REFERENCES pieces(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_pp_piece ON playlist_pieces(piece_id);",
)
.expect("init schema");
tracing::info!(%db_path, blobs = %blobs_dir.display(), "music ready");
let chord_url =
std::env::var("CHORD_URL").unwrap_or_else(|_| "http://localhost:8001".into());
let chat_gateway =
std::env::var("CHAT_GATEWAY").unwrap_or_else(|_| "http://3.135.65.204:8848/v1".into());
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());
// 关键:reqwest 默认 timeout 不要给 chat 用 —— chat stream 必须能跑很久。
// 对 chord sidecar 的小请求另外用 .timeout() per-request。
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.expect("build reqwest client");
@@ -97,6 +153,9 @@ async fn main() -> std::io::Result<()> {
blobs_dir,
chord_url,
http,
chat_gateway,
chat_token,
chat_model,
};
let api = Router::new()
@@ -108,6 +167,10 @@ async fn main() -> std::io::Result<()> {
.route("/pieces/:id/play", post(record_play))
.route("/pieces/:id/chord/fetch", post(chord_fetch))
.route("/pieces/:id/chord/status", get(chord_status))
.route(
"/pieces/:id/chat",
get(list_chat).post(post_chat).delete(clear_chat),
)
.route(
"/pieces/:id/attachments",
post(upload_attachments).layer(DefaultBodyLimit::max(REQUEST_BYTES)),
@@ -116,6 +179,18 @@ async fn main() -> std::io::Result<()> {
"/attachments/:id",
get(get_attachment).delete(delete_attachment),
)
.route("/tags", get(list_tags).post(create_tag))
.route("/tags/:id", delete(delete_tag))
.route("/playlists", get(list_playlists).post(create_playlist))
.route(
"/playlists/:id",
get(get_playlist).patch(patch_playlist).delete(delete_playlist),
)
.route("/playlists/:id/pieces", post(playlist_add_piece))
.route(
"/playlists/:id/pieces/:piece_id",
delete(playlist_remove_piece),
)
.with_state(state);
let app = cube_core::base(dist).nest("/api", api);
@@ -134,6 +209,7 @@ struct PieceSummary {
last_played_at: Option<String>,
attachments: i64,
kinds: Vec<String>,
tags: Vec<String>,
has_lyrics: bool,
created_at: String,
}
@@ -150,6 +226,7 @@ struct PieceDetail {
last_played_at: Option<String>,
created_at: String,
attachments: Vec<Attachment>,
tags: Vec<String>,
}
#[derive(Serialize)]
@@ -185,33 +262,74 @@ struct PatchPiece {
category: Option<Option<String>>,
notes: Option<Option<String>>,
lyrics: Option<Option<String>>,
/// 整体 replace;空数组等于清空
tags: Option<Vec<String>>,
/// admin / import 用:直接写 play_countmvp 无认证)
play_count: Option<i64>,
}
#[derive(Deserialize, Default)]
struct ListPiecesQuery {
tag: Option<String>,
playlist: Option<i64>,
}
// ---------- handlers: pieces ----------
async fn list_pieces(State(s): State<AppState>) -> Result<JsonResp<Value>, AppError> {
async fn list_pieces(
State(s): State<AppState>,
Query(q): Query<ListPiecesQuery>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let mut stmt = conn.prepare(
// 构造可选 filter 条件。每行 join 一次拿到 piecesubquery 单独算 attachments / tags
let (filter_join, filter_where, bind): (&str, &str, Vec<rusqlite::types::Value>) =
if let Some(t) = q.tag.as_deref().filter(|s| !s.is_empty()) {
(
"JOIN piece_tags pt ON pt.piece_id = p.id JOIN tags ft ON ft.id = pt.tag_id",
"WHERE ft.name = ?1",
vec![t.to_string().into()],
)
} else if let Some(pid) = q.playlist {
(
"JOIN playlist_pieces pp ON pp.piece_id = p.id",
"WHERE pp.playlist_id = ?1",
vec![pid.into()],
)
} else {
("", "", vec![])
};
let sql = format!(
"SELECT p.id, p.title, p.artist, p.category,
p.play_count, p.last_played_at, p.created_at,
COUNT(a.id) AS att_count,
COALESCE(GROUP_CONCAT(DISTINCT a.kind), '') AS kinds,
CASE WHEN p.lyrics IS NOT NULL AND length(p.lyrics) > 0 THEN 1 ELSE 0 END AS has_lyrics
(SELECT COUNT(*) FROM attachments a WHERE a.piece_id = p.id) AS att_count,
COALESCE((SELECT GROUP_CONCAT(DISTINCT a.kind)
FROM attachments a WHERE a.piece_id = p.id), '') AS kinds,
CASE WHEN p.lyrics IS NOT NULL AND length(p.lyrics) > 0 THEN 1 ELSE 0 END AS has_lyrics,
COALESCE((SELECT GROUP_CONCAT(t.name, char(9))
FROM piece_tags pt2 JOIN tags t ON t.id = pt2.tag_id
WHERE pt2.piece_id = p.id), '') AS tags
FROM pieces p
LEFT JOIN attachments a ON a.piece_id = p.id
{filter_join}
{filter_where}
GROUP BY p.id
ORDER BY p.title COLLATE NOCASE ASC, p.id ASC",
)?;
ORDER BY p.title COLLATE NOCASE ASC, p.id ASC"
);
let mut stmt = conn.prepare(&sql)?;
let bind_refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
let rows = stmt
.query_map([], |r| {
.query_map(bind_refs.as_slice(), |r| {
let kinds_csv: String = r.get(8)?;
let kinds = if kinds_csv.is_empty() {
Vec::new()
} else {
kinds_csv.split(',').map(|x| x.to_string()).collect()
};
let tags_raw: String = r.get(10)?;
let tags = if tags_raw.is_empty() {
Vec::new()
} else {
tags_raw.split('\t').map(|x| x.to_string()).collect()
};
let has_lyrics: i64 = r.get(9)?;
Ok(PieceSummary {
id: r.get(0)?,
@@ -223,6 +341,7 @@ async fn list_pieces(State(s): State<AppState>) -> Result<JsonResp<Value>, AppEr
created_at: r.get(6)?,
attachments: r.get(7)?,
kinds,
tags,
has_lyrics: has_lyrics != 0,
})
})?
@@ -312,6 +431,14 @@ async fn get_piece(
})?
.collect::<Result<Vec<_>, _>>()?;
let mut tag_stmt = conn.prepare(
"SELECT t.name FROM piece_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE pt.piece_id = ?1 ORDER BY t.name COLLATE NOCASE",
)?;
let tags: Vec<String> = tag_stmt
.query_map(params![id], |r| r.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(PieceDetail {
id,
title,
@@ -323,6 +450,7 @@ async fn get_piece(
last_played_at,
created_at,
attachments,
tags,
}))
}
@@ -383,6 +511,23 @@ async fn patch_piece(
params![pc, id],
)?;
}
if let Some(tags) = body.tags {
conn.execute(
"DELETE FROM piece_tags WHERE piece_id = ?1",
params![id],
)?;
for name in tags {
let trimmed = name.trim();
if trimmed.is_empty() {
continue;
}
let tag_id = upsert_tag(&conn, trimmed)?;
conn.execute(
"INSERT OR IGNORE INTO piece_tags (piece_id, tag_id) VALUES (?1, ?2)",
params![id, tag_id],
)?;
}
}
Ok(JsonResp(json!({ "ok": true })))
}
@@ -431,6 +576,511 @@ async fn delete_piece(
Ok(JsonResp(json!({ "ok": true })))
}
// ---------- handlers: chat ----------
#[derive(Serialize)]
struct ChatMessage {
id: i64,
role: String,
content: String,
created_at: String,
}
#[derive(Deserialize)]
struct PostChat {
message: String,
}
async fn list_chat(
State(s): State<AppState>,
Path(piece_id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, role, content, created_at FROM chat_messages
WHERE piece_id = ?1 ORDER BY id ASC",
)?;
let rows = stmt
.query_map(params![piece_id], |r| {
Ok(ChatMessage {
id: r.get(0)?,
role: r.get(1)?,
content: r.get(2)?,
created_at: r.get(3)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!(rows)))
}
async fn clear_chat(
State(s): State<AppState>,
Path(piece_id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
conn.execute(
"DELETE FROM chat_messages WHERE piece_id = ?1",
params![piece_id],
)?;
Ok(JsonResp(json!({ "ok": true })))
}
/// `POST /api/pieces/:id/chat` — body {"message": "..."},返回 SSE 流
/// 每个 event data 是文本片段(assistant delta content)。结束时 emit 一个 `done` event。
async fn post_chat(
State(s): State<AppState>,
Path(piece_id): Path<i64>,
JsonResp(body): JsonResp<PostChat>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> {
let user_msg = body.message.trim().to_string();
if user_msg.is_empty() {
return Err(AppError::bad_request("message required"));
}
if s.chat_token.is_empty() {
return Err(AppError::bad_request("CHAT_TOKEN not configured"));
}
// 拼 messagessystem + history + 新 user
let (system_prompt, history) = build_chat_context(&s, piece_id)?;
let mut openai_messages: Vec<Value> = Vec::new();
if !system_prompt.is_empty() {
openai_messages.push(json!({ "role": "system", "content": system_prompt }));
}
for m in &history {
openai_messages.push(json!({ "role": m.role, "content": m.content }));
}
openai_messages.push(json!({ "role": "user", "content": user_msg }));
let payload = json!({
"model": s.chat_model,
"messages": openai_messages,
"stream": true,
});
let url = format!("{}/chat/completions", s.chat_gateway.trim_end_matches('/'));
let req = s
.http
.post(&url)
.bearer_auth(&s.chat_token)
.json(&payload);
// 先存用户消息(不等 LLM 完)
{
let conn = s.db.lock().unwrap();
conn.execute(
"INSERT INTO chat_messages (piece_id, role, content) VALUES (?1, 'user', ?2)",
params![piece_id, &user_msg],
)?;
}
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(64);
let state_clone = s.clone();
tokio::spawn(async move {
let mut full = String::new();
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;
}
match serde_json::from_str::<Value>(payload) {
Ok(v) => {
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() {
full.push_str(delta);
if tx
.send(Ok(Event::default().data(delta.to_string())))
.await
.is_err()
{
// client gone
return;
}
}
}
}
Err(e) => {
tracing::warn!(error = %e, raw = %payload, "chat: bad delta json");
}
}
}
}
}
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;
}
}
// 持久化 assistant
if !full.is_empty() {
let conn = state_clone.db.lock().unwrap();
let _ = conn.execute(
"INSERT INTO chat_messages (piece_id, role, content) VALUES (?1, 'assistant', ?2)",
params![piece_id, &full],
);
}
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 build_chat_context(
s: &AppState,
piece_id: i64,
) -> Result<(String, Vec<ChatMessage>), AppError> {
let conn = s.db.lock().unwrap();
type Row = (
String,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
);
let row: Option<Row> = conn
.query_row(
"SELECT title, artist, category, lyrics, notes FROM pieces WHERE id = ?1",
params![piece_id],
|r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
))
},
)
.optional()?;
let (title, artist, category, lyrics, notes) = row.ok_or(AppError::NotFound)?;
let mut sys = String::from(
"你是麻薯,一个懂音乐、会乐理、爱聊天的助手。用中文回答,简洁直接,必要时用 markdown。\n\n当前曲目:",
);
sys.push_str(&format!("{}", title));
if let Some(a) = artist.as_deref().filter(|s| !s.is_empty()) {
sys.push_str(&format!("{}", a));
}
if let Some(c) = category.as_deref().filter(|s| !s.is_empty()) {
sys.push_str(&format!("{}", c));
}
if let Some(n) = notes.as_deref().filter(|s| !s.is_empty()) {
sys.push_str(&format!("\n用户笔记:{}", n));
}
if let Some(l) = lyrics.as_deref().filter(|s| !s.is_empty()) {
// LRC 太长会爆 prompt,截到 4KB
let trimmed = if l.len() > 4096 { &l[..4096] } else { l };
sys.push_str(&format!("\n歌词(截断到 4KB):\n{}", trimmed));
}
let mut stmt = conn.prepare(
"SELECT id, role, content, created_at FROM chat_messages
WHERE piece_id = ?1 ORDER BY id ASC",
)?;
let history = stmt
.query_map(params![piece_id], |r| {
Ok(ChatMessage {
id: r.get(0)?,
role: r.get(1)?,
content: r.get(2)?,
created_at: r.get(3)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok((sys, history))
}
// ---------- handlers: tags ----------
async fn list_tags(State(s): State<AppState>) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT t.id, t.name, COUNT(pt.piece_id) AS n
FROM tags t LEFT JOIN piece_tags pt ON pt.tag_id = t.id
GROUP BY t.id ORDER BY t.name COLLATE NOCASE ASC",
)?;
let rows: Vec<Value> = stmt
.query_map([], |r| {
Ok(json!({
"id": r.get::<_, i64>(0)?,
"name": r.get::<_, String>(1)?,
"count": r.get::<_, i64>(2)?,
}))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!(rows)))
}
#[derive(Deserialize)]
struct CreateTag {
name: String,
}
async fn create_tag(
State(s): State<AppState>,
JsonResp(body): JsonResp<CreateTag>,
) -> Result<JsonResp<Value>, AppError> {
let name = body.name.trim();
if name.is_empty() {
return Err(AppError::bad_request("name required"));
}
let conn = s.db.lock().unwrap();
let id = upsert_tag(&conn, name)?;
Ok(JsonResp(json!({ "id": id, "name": name })))
}
fn upsert_tag(conn: &Connection, name: &str) -> Result<i64, rusqlite::Error> {
conn.execute(
"INSERT INTO tags (name) VALUES (?1) ON CONFLICT(name) DO NOTHING",
params![name],
)?;
conn.query_row("SELECT id FROM tags WHERE name = ?1", params![name], |r| r.get(0))
}
async fn delete_tag(
State(s): State<AppState>,
Path(id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let n = conn.execute("DELETE FROM tags WHERE id = ?1", params![id])?;
if n == 0 {
return Err(AppError::NotFound);
}
Ok(JsonResp(json!({ "ok": true })))
}
// ---------- handlers: playlists ----------
async fn list_playlists(State(s): State<AppState>) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT p.id, p.name, p.description, p.created_at,
COUNT(pp.piece_id) AS n
FROM playlists p LEFT JOIN playlist_pieces pp ON pp.playlist_id = p.id
GROUP BY p.id ORDER BY p.created_at DESC, p.id DESC",
)?;
let rows: Vec<Value> = stmt
.query_map([], |r| {
Ok(json!({
"id": r.get::<_, i64>(0)?,
"name": r.get::<_, String>(1)?,
"description": r.get::<_, Option<String>>(2)?,
"created_at": r.get::<_, String>(3)?,
"count": r.get::<_, i64>(4)?,
}))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!(rows)))
}
#[derive(Deserialize)]
struct CreatePlaylist {
name: String,
description: Option<String>,
}
async fn create_playlist(
State(s): State<AppState>,
JsonResp(body): JsonResp<CreatePlaylist>,
) -> Result<JsonResp<Value>, AppError> {
let name = body.name.trim();
if name.is_empty() {
return Err(AppError::bad_request("name required"));
}
let conn = s.db.lock().unwrap();
conn.execute(
"INSERT INTO playlists (name, description) VALUES (?1, ?2)",
params![name, body.description.as_deref()],
)?;
Ok(JsonResp(json!({ "id": conn.last_insert_rowid() })))
}
async fn get_playlist(
State(s): State<AppState>,
Path(id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let meta: Option<(String, Option<String>, String)> = conn
.query_row(
"SELECT name, description, created_at FROM playlists WHERE id = ?1",
params![id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?;
let (name, description, created_at) = meta.ok_or(AppError::NotFound)?;
let mut stmt = conn.prepare(
"SELECT p.id, p.title, p.artist, p.category, p.play_count, p.last_played_at,
p.created_at,
(SELECT COUNT(*) FROM attachments a WHERE a.piece_id = p.id) AS att_count,
(SELECT COALESCE(GROUP_CONCAT(DISTINCT a.kind), '')
FROM attachments a WHERE a.piece_id = p.id) AS kinds,
CASE WHEN p.lyrics IS NOT NULL AND length(p.lyrics) > 0 THEN 1 ELSE 0 END,
pp.sort_order
FROM playlist_pieces pp JOIN pieces p ON p.id = pp.piece_id
WHERE pp.playlist_id = ?1
ORDER BY pp.sort_order ASC, pp.added_at ASC",
)?;
let pieces: Vec<Value> = stmt
.query_map(params![id], |r| {
let kinds_csv: String = r.get(8)?;
let kinds: Vec<&str> = if kinds_csv.is_empty() {
Vec::new()
} else {
kinds_csv.split(',').collect()
};
let has_lyrics: i64 = r.get(9)?;
Ok(json!({
"id": r.get::<_, i64>(0)?,
"title": r.get::<_, String>(1)?,
"artist": r.get::<_, Option<String>>(2)?,
"category": r.get::<_, Option<String>>(3)?,
"play_count": r.get::<_, i64>(4)?,
"last_played_at": r.get::<_, Option<String>>(5)?,
"created_at": r.get::<_, String>(6)?,
"attachments": r.get::<_, i64>(7)?,
"kinds": kinds,
"has_lyrics": has_lyrics != 0,
"sort_order": r.get::<_, i64>(10)?,
}))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!({
"id": id,
"name": name,
"description": description,
"created_at": created_at,
"pieces": pieces,
})))
}
#[derive(Deserialize)]
struct PatchPlaylist {
name: Option<String>,
description: Option<Option<String>>,
}
async fn patch_playlist(
State(s): State<AppState>,
Path(id): Path<i64>,
JsonResp(body): JsonResp<PatchPlaylist>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let exists: bool = conn
.query_row("SELECT 1 FROM playlists WHERE id = ?1", params![id], |_| Ok(true))
.optional()?
.unwrap_or(false);
if !exists {
return Err(AppError::NotFound);
}
if let Some(n) = body.name.as_ref() {
let n = n.trim();
if n.is_empty() {
return Err(AppError::bad_request("name can't be blank"));
}
conn.execute(
"UPDATE playlists SET name = ?1 WHERE id = ?2",
params![n, id],
)?;
}
if let Some(d) = body.description {
conn.execute(
"UPDATE playlists SET description = ?1 WHERE id = ?2",
params![d.as_deref(), id],
)?;
}
Ok(JsonResp(json!({ "ok": true })))
}
async fn delete_playlist(
State(s): State<AppState>,
Path(id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
let n = conn.execute("DELETE FROM playlists WHERE id = ?1", params![id])?;
if n == 0 {
return Err(AppError::NotFound);
}
Ok(JsonResp(json!({ "ok": true })))
}
#[derive(Deserialize)]
struct AddPiece {
piece_id: i64,
}
async fn playlist_add_piece(
State(s): State<AppState>,
Path(id): Path<i64>,
JsonResp(body): JsonResp<AddPiece>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
conn.execute(
"INSERT INTO playlist_pieces (playlist_id, piece_id, sort_order)
VALUES (?1, ?2,
COALESCE((SELECT MAX(sort_order) FROM playlist_pieces WHERE playlist_id = ?1), 0) + 1)
ON CONFLICT(playlist_id, piece_id) DO NOTHING",
params![id, body.piece_id],
)?;
Ok(JsonResp(json!({ "ok": true })))
}
async fn playlist_remove_piece(
State(s): State<AppState>,
Path((id, piece_id)): Path<(i64, i64)>,
) -> Result<JsonResp<Value>, AppError> {
let conn = s.db.lock().unwrap();
conn.execute(
"DELETE FROM playlist_pieces WHERE playlist_id = ?1 AND piece_id = ?2",
params![id, piece_id],
)?;
Ok(JsonResp(json!({ "ok": true })))
}
// ---------- handlers: chord auto-fetch ----------
/// `POST /api/pieces/:id/chord/fetch` — 触发 sidecar 抓取 yopu 和弦谱。
@@ -452,6 +1102,7 @@ async fn chord_fetch(
.http
.post(&url)
.query(&[("piece_id", piece_id.to_string()), ("query", query)])
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| AppError::sidecar(format!("post fetch: {e}")))?;
@@ -481,6 +1132,7 @@ async fn chord_status(
let resp = s
.http
.get(&url)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| AppError::sidecar(format!("get status: {e}")))?;
@@ -504,6 +1156,7 @@ async fn chord_status(
let _ = s
.http
.delete(format!("{}/state/{}", s.chord_url, piece_id))
.timeout(std::time::Duration::from_secs(5))
.send()
.await;
return Ok(JsonResp(json!({