music(inspire): 加「💡 今天练什么」灵感推荐 modal
deploy music / build-and-deploy (push) Failing after 1m50s

- 后端 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:
Fam Zheng
2026-05-10 15:52:00 +01:00
parent f7fac352a5
commit ccb5ad05ce
4 changed files with 512 additions and 0 deletions
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// **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%;