- 后端 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:
@@ -133,6 +133,47 @@ export async function streamChat(pieceId, message, onDelta, signal) {
|
||||
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() {
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// **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%;
|
||||
|
||||
Reference in New Issue
Block a user