Add memory coach, transformer recall model, and training FAB.
Introduce Q/K/V memory dialogue with coach APIs, a lightweight NumPy transformer for per-word forgetting prediction, and a floating training menu linking daily quiz, spell, and coach flows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -172,6 +172,71 @@ export interface Settings {
|
||||
weak_wrong_threshold: number
|
||||
}
|
||||
|
||||
export interface MemoryToken {
|
||||
role: string
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
revealed: boolean
|
||||
}
|
||||
|
||||
export interface CoachWordBrief {
|
||||
word_id: number
|
||||
zh: string
|
||||
phonetic?: string | null
|
||||
retention_now: number
|
||||
mastery_score: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface CoachSessionResponse {
|
||||
words: CoachWordBrief[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface MemoryTransformerCurvePoint {
|
||||
hours_ahead: number
|
||||
recall_percent: number
|
||||
forgetting_percent: number
|
||||
}
|
||||
|
||||
export interface MemoryTransformerAttention {
|
||||
index: number
|
||||
label: string
|
||||
weight: number
|
||||
}
|
||||
|
||||
export interface MemoryTransformerPredict {
|
||||
word_id: number
|
||||
en: string
|
||||
recall_now_percent: number
|
||||
formula_recall_percent: number
|
||||
model_recall_percent: number
|
||||
blend_weight: number
|
||||
event_count: number
|
||||
model_trained: boolean
|
||||
recommended_review_days: number
|
||||
half_life_hours?: number | null
|
||||
curve: MemoryTransformerCurvePoint[]
|
||||
attention_hint: MemoryTransformerAttention[]
|
||||
}
|
||||
|
||||
export interface CoachTurnResponse {
|
||||
assistant_messages: string[]
|
||||
tokens?: MemoryToken[]
|
||||
stage: string
|
||||
expect_input: boolean
|
||||
prompt_zh?: string
|
||||
prompt_phonetic?: string | null
|
||||
input_hint?: string
|
||||
is_correct?: boolean | null
|
||||
quiz_recorded: boolean
|
||||
word_complete: boolean
|
||||
hints_used: number
|
||||
target_en?: string | null
|
||||
target_zh?: string | null
|
||||
}
|
||||
|
||||
export const api = {
|
||||
register: (username: string, password: string) =>
|
||||
request.post('/auth/register', { username, password }),
|
||||
@@ -185,6 +250,8 @@ export const api = {
|
||||
getWord: (id: number) => request.get<Word>(`/words/${id}`),
|
||||
memoryViz: () => request.get<MemoryVisualization>('/words/memory-viz'),
|
||||
wordMemory: (id: number) => request.get<WordMemoryDetail>(`/words/${id}/memory`),
|
||||
wordMemoryModel: (id: number) =>
|
||||
request.get<MemoryTransformerPredict>(`/words/${id}/memory-model`),
|
||||
deleteWord: (id: number) => request.delete(`/words/${id}`),
|
||||
dailyQuiz: () =>
|
||||
request.get<{ questions: QuizQuestion[]; total: number }>('/quiz/daily'),
|
||||
@@ -200,4 +267,12 @@ export const api = {
|
||||
quizStats: () => request.get<QuizStats>('/quiz/stats'),
|
||||
getSettings: () => request.get<Settings>('/settings'),
|
||||
updateSettings: (data: Partial<Settings>) => request.patch<Settings>('/settings', data),
|
||||
coachSession: () => request.get<CoachSessionResponse>('/coach/session'),
|
||||
coachTurn: (data: {
|
||||
word_id: number
|
||||
stage?: string
|
||||
user_message?: string
|
||||
hints_used?: number
|
||||
duration_seconds?: number
|
||||
}) => request.post<CoachTurnResponse>('/coach/turn', data),
|
||||
}
|
||||
|
||||
@@ -22,8 +22,10 @@ const typeLabel: Record<string, string> = {
|
||||
|
||||
<template>
|
||||
<div class="card quiz-card">
|
||||
<div class="quiz-progress">{{ index + 1 }} / {{ total }}</div>
|
||||
<div class="quiz-type">{{ typeLabel[question.question_type] || question.question_type }}</div>
|
||||
<div class="quiz-meta">
|
||||
<span>{{ index + 1 }}/{{ total }}</span>
|
||||
<span>{{ typeLabel[question.question_type] || question.question_type }}</span>
|
||||
</div>
|
||||
<div class="quiz-prompt">{{ question.prompt }}</div>
|
||||
<div class="options">
|
||||
<button
|
||||
@@ -41,29 +43,27 @@ const typeLabel: Record<string, string> = {
|
||||
<span class="opt-label">{{ opt.label }}.</span> {{ opt.text }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showResult" class="result" :class="isCorrect ? 'ok' : 'fail'">
|
||||
{{ isCorrect ? '回答正确 ✅' : '回答错误 ❌' }}
|
||||
<template v-if="!isCorrect"> — 正确答案:{{ question.correct_answer }}</template>
|
||||
</div>
|
||||
<p v-if="showResult" class="result" :class="isCorrect ? 'ok' : 'fail'">
|
||||
<template v-if="isCorrect">正确</template>
|
||||
<template v-else>错误,答案 {{ question.correct_answer }}</template>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quiz-progress {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.quiz-type {
|
||||
.quiz-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
margin-bottom: 8px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.quiz-prompt {
|
||||
font-size: 28px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
margin: 8px 0 24px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.options {
|
||||
display: flex;
|
||||
@@ -96,12 +96,10 @@ const typeLabel: Record<string, string> = {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.result {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius);
|
||||
margin: 16px 0 0;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.result.ok { background: #d1fae5; color: #047857; }
|
||||
.result.fail { background: #fee2e2; color: #b91c1c; }
|
||||
.result.ok { color: var(--success); }
|
||||
.result.fail { color: var(--danger); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const open = ref(false)
|
||||
|
||||
const trainPaths = ['/quiz', '/spell', '/coach', '/graph-practice']
|
||||
|
||||
const isTrainRoute = computed(() =>
|
||||
trainPaths.some((p) => route.path === p || route.path.startsWith(p + '/'))
|
||||
)
|
||||
|
||||
const items = [
|
||||
{ to: '/quiz', label: '每日训练', icon: '✏️' },
|
||||
{ to: '/spell', label: '拼写练习', icon: '⌨️' },
|
||||
{ to: '/coach', label: '记忆对话', icon: '💬' },
|
||||
]
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
function go(to: string) {
|
||||
open.value = false
|
||||
if (route.path !== to) router.push(to)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
open.value = false
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="train-fab-wrap">
|
||||
<div v-if="open" class="train-fab-backdrop" @click="open = false" />
|
||||
|
||||
<transition name="train-menu">
|
||||
<ul v-if="open" class="train-fab-menu" role="menu">
|
||||
<li v-for="item in items" :key="item.to" role="none">
|
||||
<button
|
||||
type="button"
|
||||
class="train-fab-menu-item"
|
||||
role="menuitem"
|
||||
@click="go(item.to)"
|
||||
>
|
||||
<span class="menu-icon">{{ item.icon }}</span>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</transition>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="train-fab-btn"
|
||||
:class="{ active: isTrainRoute, open }"
|
||||
aria-label="训练菜单"
|
||||
:aria-expanded="open"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="fab-icon">{{ open ? '×' : '✏️' }}</span>
|
||||
<span class="fab-label">训练</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.train-fab-wrap {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: calc(64px + env(safe-area-inset-bottom));
|
||||
z-index: 110;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.train-fab-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: rgba(15, 23, 42, 0.25);
|
||||
}
|
||||
|
||||
.train-fab-menu {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 28px rgba(79, 110, 247, 0.2);
|
||||
border: 1px solid var(--border);
|
||||
min-width: 148px;
|
||||
}
|
||||
|
||||
.train-fab-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.train-fab-menu-item:hover {
|
||||
background: rgba(79, 110, 247, 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.train-fab-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(79, 110, 247, 0.45);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.train-fab-btn:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.train-fab-btn.active {
|
||||
box-shadow: 0 4px 20px rgba(79, 110, 247, 0.55);
|
||||
}
|
||||
|
||||
.train-fab-btn.open {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fab-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.train-menu-enter-active,
|
||||
.train-menu-leave-active {
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.train-menu-enter-from,
|
||||
.train-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="main-layout">
|
||||
<router-view />
|
||||
<TrainFab />
|
||||
<nav class="nav-bottom">
|
||||
<router-link to="/" class="nav-item">
|
||||
<span class="nav-icon">📊</span>
|
||||
@@ -14,10 +15,6 @@
|
||||
<span class="nav-icon">📚</span>
|
||||
词库
|
||||
</router-link>
|
||||
<router-link to="/quiz" class="nav-item">
|
||||
<span class="nav-icon">✏️</span>
|
||||
训练
|
||||
</router-link>
|
||||
<router-link to="/settings" class="nav-item">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
设置
|
||||
@@ -25,3 +22,7 @@
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TrainFab from '../components/TrainFab.vue'
|
||||
</script>
|
||||
|
||||
@@ -131,32 +131,27 @@ const accuracy = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">每日训练</h1>
|
||||
<router-link to="/spell" class="spell-link">拼写练习 →</router-link>
|
||||
</div>
|
||||
<div class="page quiz-page">
|
||||
<h1 class="page-title">每日训练</h1>
|
||||
|
||||
<p v-if="restored && !loading && !finished && !empty" class="restore-hint">
|
||||
已恢复上次未完成的训练进度
|
||||
<p v-if="restored && !loading && !finished && !empty" class="quiz-hint">
|
||||
已恢复上次进度
|
||||
</p>
|
||||
|
||||
<p v-if="loading" style="color: var(--muted)">加载题目...</p>
|
||||
<p v-if="loading" class="quiz-muted">加载中…</p>
|
||||
|
||||
<div v-else-if="empty" class="card" style="text-align: center">
|
||||
<p>词库单词不足,请先通过翻译添加至少 4 个单词。</p>
|
||||
<router-link to="/translate" class="btn btn-primary" style="margin-top: 12px; display: inline-block">
|
||||
去翻译
|
||||
</router-link>
|
||||
<div v-else-if="empty" class="quiz-empty">
|
||||
<p class="quiz-muted">词库至少需 4 个单词</p>
|
||||
<router-link to="/translate" class="quiz-link">去添加</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else-if="finished" class="card summary">
|
||||
<h2>本次训练完成 🎉</h2>
|
||||
<p>总题数:{{ sessionCorrect + sessionWrong }}</p>
|
||||
<p>答对:{{ sessionCorrect }}</p>
|
||||
<p>答错:{{ sessionWrong }}</p>
|
||||
<p>正确率:{{ accuracy() }}%</p>
|
||||
<router-link to="/" class="btn btn-primary" style="margin-top: 16px">返回首页</router-link>
|
||||
<div v-else-if="finished" class="quiz-done">
|
||||
<p class="quiz-done-rate">{{ accuracy() }}%</p>
|
||||
<p class="quiz-done-meta">
|
||||
{{ sessionCorrect }} 对 · {{ sessionWrong }} 错 · 共
|
||||
{{ sessionCorrect + sessionWrong }} 题
|
||||
</p>
|
||||
<router-link to="/" class="btn btn-primary quiz-done-btn">完成</router-link>
|
||||
</div>
|
||||
|
||||
<template v-else-if="current()">
|
||||
@@ -171,44 +166,60 @@ const accuracy = () => {
|
||||
/>
|
||||
<button
|
||||
v-if="showResult"
|
||||
class="btn btn-primary"
|
||||
style="margin-top: 12px"
|
||||
class="btn btn-primary quiz-next"
|
||||
@click="nextQuestion"
|
||||
>
|
||||
{{ currentIndex >= questions.length - 1 ? '查看结果' : '下一题' }}
|
||||
{{ currentIndex >= questions.length - 1 ? '结果' : '下一题' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.page-header .page-title {
|
||||
margin: 0;
|
||||
}
|
||||
.spell-link {
|
||||
font-size: 14px;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.restore-hint {
|
||||
.quiz-hint,
|
||||
.quiz-muted {
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
margin-bottom: 12px;
|
||||
color: var(--muted);
|
||||
margin: -12px 0 16px;
|
||||
}
|
||||
.summary h2 {
|
||||
margin-bottom: 12px;
|
||||
font-size: 20px;
|
||||
|
||||
.quiz-empty {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.summary p {
|
||||
margin: 6px 0;
|
||||
|
||||
.quiz-link {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.quiz-done {
|
||||
text-align: center;
|
||||
padding: 40px 0 16px;
|
||||
}
|
||||
|
||||
.quiz-done-rate {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
margin: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.quiz-done-meta {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
margin: 12px 0 28px;
|
||||
}
|
||||
|
||||
.quiz-done-btn {
|
||||
max-width: 200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.quiz-next {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -62,6 +62,7 @@ onMounted(async () => {
|
||||
<router-link to="/words" class="btn btn-outline">单词库</router-link>
|
||||
<router-link to="/quiz" class="btn btn-primary">开始每日训练</router-link>
|
||||
<router-link to="/spell" class="btn btn-outline">拼写练习</router-link>
|
||||
<router-link to="/coach" class="btn btn-outline">记忆对话</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import {
|
||||
api,
|
||||
type CoachTurnResponse,
|
||||
type CoachWordBrief,
|
||||
type MemoryToken,
|
||||
} from '../api/request'
|
||||
import { useQuizTimer } from '../composables/useQuizTimer'
|
||||
|
||||
interface ChatLine {
|
||||
role: 'assistant' | 'user'
|
||||
content: string
|
||||
}
|
||||
|
||||
const { startQuestionTimer, consumeDurationSeconds } = useQuizTimer()
|
||||
|
||||
const loading = ref(true)
|
||||
const empty = ref(false)
|
||||
const words = ref<CoachWordBrief[]>([])
|
||||
const wordIndex = ref(0)
|
||||
const stage = ref('intro')
|
||||
const hintsUsed = ref(0)
|
||||
const tokens = ref<MemoryToken[]>([])
|
||||
const promptZh = ref('')
|
||||
const promptPhonetic = ref<string | null>(null)
|
||||
const chatLines = ref<ChatLine[]>([])
|
||||
const userInput = ref('')
|
||||
const sending = ref(false)
|
||||
const expectInput = ref(false)
|
||||
const sessionDone = ref(false)
|
||||
const showTokens = ref(false)
|
||||
const chatEndRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const currentWord = computed(() => words.value[wordIndex.value])
|
||||
|
||||
const promptLabel = computed(() => promptZh.value || currentWord.value?.zh || '')
|
||||
|
||||
const phoneticLabel = computed(
|
||||
() => promptPhonetic.value || currentWord.value?.phonetic || ''
|
||||
)
|
||||
|
||||
const inputPlaceholder = computed(() => {
|
||||
const zh = promptLabel.value
|
||||
return zh ? `输入「${zh}」的英文` : '输入英文'
|
||||
})
|
||||
|
||||
const kTokens = computed(() => tokens.value.filter((t) => t.role === 'K'))
|
||||
const qTokens = computed(() => tokens.value.filter((t) => t.role === 'Q'))
|
||||
const vTokens = computed(() => tokens.value.filter((t) => t.role === 'V' && t.revealed))
|
||||
|
||||
function tokenClass(role: string) {
|
||||
if (role === 'Q') return 'pill-q'
|
||||
if (role === 'K') return 'pill-k'
|
||||
return 'pill-v'
|
||||
}
|
||||
|
||||
function scrollChat() {
|
||||
nextTick(() => {
|
||||
chatEndRef.value?.scrollIntoView({ behavior: 'smooth' })
|
||||
})
|
||||
}
|
||||
|
||||
function appendAssistant(msgs: string[]) {
|
||||
for (const m of msgs) {
|
||||
if (m.trim()) chatLines.value.push({ role: 'assistant', content: m })
|
||||
}
|
||||
scrollChat()
|
||||
}
|
||||
|
||||
function applyTurn(res: CoachTurnResponse) {
|
||||
tokens.value = res.tokens ?? []
|
||||
stage.value = res.stage
|
||||
expectInput.value = res.expect_input
|
||||
hintsUsed.value = res.hints_used
|
||||
if (res.prompt_zh) promptZh.value = res.prompt_zh
|
||||
if (res.prompt_phonetic !== undefined) promptPhonetic.value = res.prompt_phonetic
|
||||
appendAssistant(res.assistant_messages)
|
||||
}
|
||||
|
||||
async function runTurn(message: string) {
|
||||
const w = currentWord.value
|
||||
if (!w || sending.value) return
|
||||
sending.value = true
|
||||
try {
|
||||
const duration =
|
||||
stage.value === 'derive' && message
|
||||
? consumeDurationSeconds()
|
||||
: undefined
|
||||
const { data } = await api.coachTurn({
|
||||
word_id: w.word_id,
|
||||
stage: stage.value,
|
||||
user_message: message,
|
||||
hints_used: hintsUsed.value,
|
||||
duration_seconds: duration,
|
||||
})
|
||||
applyTurn(data)
|
||||
if (data.word_complete && data.stage === 'done') {
|
||||
expectInput.value = false
|
||||
}
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startCurrentWord() {
|
||||
const w = currentWord.value
|
||||
chatLines.value = []
|
||||
stage.value = 'intro'
|
||||
hintsUsed.value = 0
|
||||
userInput.value = ''
|
||||
showTokens.value = false
|
||||
promptZh.value = w?.zh ?? ''
|
||||
promptPhonetic.value = w?.phonetic ?? null
|
||||
startQuestionTimer()
|
||||
await runTurn('')
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = userInput.value.trim()
|
||||
if (!text) return
|
||||
chatLines.value.push({ role: 'user', content: text })
|
||||
userInput.value = ''
|
||||
scrollChat()
|
||||
await runTurn(text)
|
||||
}
|
||||
|
||||
function nextWord() {
|
||||
if (wordIndex.value >= words.value.length - 1) {
|
||||
sessionDone.value = true
|
||||
return
|
||||
}
|
||||
wordIndex.value += 1
|
||||
startCurrentWord()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.coachSession()
|
||||
words.value = data.words
|
||||
empty.value = data.total === 0
|
||||
if (!empty.value) await startCurrentWord()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page coach-page">
|
||||
<header class="coach-header">
|
||||
<h1 class="page-title">记忆对话</h1>
|
||||
<span v-if="!empty && !sessionDone" class="coach-count">
|
||||
{{ wordIndex + 1 }} / {{ words.length }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<p v-if="loading" class="muted">加载中...</p>
|
||||
<p v-else-if="empty" class="muted">
|
||||
暂无待练单词,<router-link to="/translate">去加词</router-link>
|
||||
</p>
|
||||
|
||||
<template v-else-if="!sessionDone">
|
||||
<section class="coach-main card">
|
||||
<p v-if="promptLabel" class="coach-zh">{{ promptLabel }}</p>
|
||||
<p v-if="phoneticLabel" class="coach-phonetic">{{ phoneticLabel }}</p>
|
||||
<p v-if="currentWord" class="coach-meta">
|
||||
记忆 {{ currentWord.retention_now }}% · 掌握 {{ currentWord.mastery_score }}%
|
||||
<span class="qkv-hint">· K/Q/V 推导</span>
|
||||
</p>
|
||||
|
||||
<button type="button" class="token-toggle" @click="showTokens = !showTokens">
|
||||
{{ showTokens ? '收起线索' : '展开 Q/K/V 线索' }}
|
||||
<span v-if="hintsUsed" class="hint-badge">{{ hintsUsed }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="showTokens && tokens.length" class="token-groups">
|
||||
<div v-if="kTokens.length" class="token-group">
|
||||
<span class="group-tag pill-k">K</span>
|
||||
<span
|
||||
v-for="t in kTokens"
|
||||
:key="t.key"
|
||||
class="pill"
|
||||
:class="tokenClass('K')"
|
||||
>
|
||||
{{ t.label }} {{ t.value }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="qTokens.length" class="token-group">
|
||||
<span class="group-tag pill-q">Q</span>
|
||||
<span
|
||||
v-for="t in qTokens"
|
||||
:key="t.key"
|
||||
class="pill"
|
||||
:class="[tokenClass('Q'), { dim: !t.revealed }]"
|
||||
>
|
||||
{{ t.label }} {{ t.revealed ? t.value : '…' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="vTokens.length" class="token-group">
|
||||
<span class="group-tag pill-v">V</span>
|
||||
<span
|
||||
v-for="t in vTokens"
|
||||
:key="t.key"
|
||||
class="pill"
|
||||
:class="tokenClass('V')"
|
||||
>
|
||||
{{ t.label }} {{ t.value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chatLines.length" class="chat-feed">
|
||||
<div
|
||||
v-for="(line, i) in chatLines"
|
||||
:key="i"
|
||||
class="chat-line"
|
||||
:class="line.role"
|
||||
>
|
||||
{{ line.content }}
|
||||
</div>
|
||||
<div ref="chatEndRef" />
|
||||
</div>
|
||||
|
||||
<div v-if="expectInput" class="input-bar">
|
||||
<input
|
||||
v-model="userInput"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="inputPlaceholder"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
@keydown.enter.prevent="sendMessage"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary send-btn"
|
||||
:disabled="sending"
|
||||
@click="sendMessage"
|
||||
>
|
||||
提交
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else-if="stage === 'done'"
|
||||
type="button"
|
||||
class="btn btn-primary next-btn"
|
||||
@click="nextWord"
|
||||
>
|
||||
{{ wordIndex >= words.length - 1 ? '完成' : '下一个' }}
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<section v-else class="card done-card">
|
||||
<p>本轮已完成</p>
|
||||
<router-link to="/" class="btn btn-outline">回首页</router-link>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.coach-page {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.coach-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.coach-header .page-title {
|
||||
margin: 0;
|
||||
}
|
||||
.coach-count {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.coach-main {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.coach-zh {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.coach-phonetic {
|
||||
font-size: 15px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.coach-meta {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.qkv-hint {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.token-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hint-badge {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.token-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.token-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.group-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pill {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
}
|
||||
.pill.dim {
|
||||
opacity: 0.5;
|
||||
border-style: dashed;
|
||||
}
|
||||
.pill-k,
|
||||
.group-tag.pill-k {
|
||||
background: #f5f3ff;
|
||||
color: #5b21b6;
|
||||
border-color: #ddd6fe;
|
||||
}
|
||||
.pill-q,
|
||||
.group-tag.pill-q {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
.pill-v,
|
||||
.group-tag.pill-v {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
.chat-feed {
|
||||
max-height: 28vh;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 14px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.chat-line {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.chat-line.user {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
.chat-line.assistant {
|
||||
color: var(--text);
|
||||
}
|
||||
.input-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.input-bar .input {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
.send-btn {
|
||||
width: auto;
|
||||
min-width: 64px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.next-btn {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.done-card {
|
||||
text-align: center;
|
||||
padding: 28px 20px;
|
||||
}
|
||||
.done-card p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type MemoryWordSummary,
|
||||
type Word,
|
||||
type WordMemoryDetail,
|
||||
type MemoryTransformerPredict,
|
||||
} from '../api/request'
|
||||
import { formatTrainSeconds } from '../composables/useQuizTimer'
|
||||
import {
|
||||
@@ -72,6 +73,8 @@ const selectedWord = ref<MemoryWordSummary | null>(null)
|
||||
const detailExpanded = ref(true)
|
||||
const wordMemory = ref<WordMemoryDetail | null>(null)
|
||||
const wordMemoryLoading = ref(false)
|
||||
const memoryModel = ref<MemoryTransformerPredict | null>(null)
|
||||
const memoryModelLoading = ref(false)
|
||||
|
||||
const {
|
||||
graphStatusFilter,
|
||||
@@ -166,13 +169,20 @@ async function handleDelete(id: number) {
|
||||
|
||||
async function loadWordMemory(wordId: number) {
|
||||
wordMemoryLoading.value = true
|
||||
memoryModelLoading.value = true
|
||||
try {
|
||||
const { data } = await api.wordMemory(wordId)
|
||||
wordMemory.value = data
|
||||
const [memRes, modelRes] = await Promise.all([
|
||||
api.wordMemory(wordId),
|
||||
api.wordMemoryModel(wordId),
|
||||
])
|
||||
wordMemory.value = memRes.data
|
||||
memoryModel.value = modelRes.data
|
||||
} catch {
|
||||
wordMemory.value = null
|
||||
memoryModel.value = null
|
||||
} finally {
|
||||
wordMemoryLoading.value = false
|
||||
memoryModelLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +474,24 @@ onMounted(() => {
|
||||
<span>当前记忆保留 {{ selectedWord.retention_now }}%</span>
|
||||
<span>7 日后预测 {{ selectedWord.risk_7d }}%</span>
|
||||
</div>
|
||||
<div v-if="memoryModel && !memoryModelLoading" class="model-panel">
|
||||
<h4 class="word-curve-title">Transformer 记忆预测</h4>
|
||||
<p class="section-desc">
|
||||
模型回忆率 {{ memoryModel.model_recall_percent }}% · 公式
|
||||
{{ memoryModel.formula_recall_percent }}% · 融合
|
||||
{{ memoryModel.recall_now_percent }}% · 建议
|
||||
{{ memoryModel.recommended_review_days }} 天后复习
|
||||
</p>
|
||||
<div class="attn-chips">
|
||||
<span
|
||||
v-for="a in memoryModel.attention_hint"
|
||||
:key="a.index"
|
||||
class="attn-chip"
|
||||
>
|
||||
{{ a.label }} {{ (a.weight * 100).toFixed(0) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="wordMemoryLoading" class="curve-loading">加载该词记忆曲线...</p>
|
||||
<template v-else-if="wordCurvePoints.length">
|
||||
<h4 class="word-curve-title">该单词记忆曲线</h4>
|
||||
@@ -503,6 +531,26 @@ onMounted(() => {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.model-panel {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px;
|
||||
background: rgba(79, 110, 247, 0.06);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid rgba(79, 110, 247, 0.15);
|
||||
}
|
||||
.attn-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.attn-chip {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.page-summary {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -21,6 +21,11 @@ const router = createRouter({
|
||||
name: 'GraphPractice',
|
||||
component: () => import('../pages/GraphPractice.vue'),
|
||||
},
|
||||
{
|
||||
path: 'coach',
|
||||
name: 'MemoryCoach',
|
||||
component: () => import('../pages/MemoryCoach.vue'),
|
||||
},
|
||||
{ path: 'settings', name: 'Settings', component: () => import('../pages/Settings.vue') },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -103,7 +103,7 @@ a {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
padding-bottom: 80px;
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/request.ts","./src/router/index.ts","./src/app.vue","./src/components/quizcard.vue","./src/components/wordcard.vue","./src/layouts/mainlayout.vue","./src/pages/dailyquiz.vue","./src/pages/dashboard.vue","./src/pages/login.vue","./src/pages/register.vue","./src/pages/settings.vue","./src/pages/translate.vue","./src/pages/wordlibrary.vue"],"version":"5.6.3"}
|
||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/request.ts","./src/composables/usegraphfilter.ts","./src/composables/usequizsession.ts","./src/composables/usequiztimer.ts","./src/router/index.ts","./src/utils/auth.ts","./src/utils/buildpracticequestion.ts","./src/utils/practicefocus.ts","./src/utils/practicegraphdisplay.ts","./src/utils/practicegraphsession.ts","./src/utils/spellquestion.ts","./src/app.vue","./src/components/memorycurvechart.vue","./src/components/quizcard.vue","./src/components/spellcard.vue","./src/components/trainfab.vue","./src/components/wordcard.vue","./src/components/wordgraphcanvas.vue","./src/layouts/mainlayout.vue","./src/pages/dailyquiz.vue","./src/pages/dashboard.vue","./src/pages/graphpractice.vue","./src/pages/login.vue","./src/pages/memorycoach.vue","./src/pages/register.vue","./src/pages/settings.vue","./src/pages/spellquiz.vue","./src/pages/translate.vue","./src/pages/wordlibrary.vue"],"version":"5.6.3"}
|
||||
Reference in New Issue
Block a user