feat: polish voice studio workflow and bilingual copy
This commit is contained in:
@@ -135,7 +135,7 @@ onMounted(fetchTimeline)
|
||||
<!-- 暂无数据 -->
|
||||
<div v-if="events.length === 0" class="text-center py-20 bg-white/50 backdrop-blur rounded-3xl border border-white">
|
||||
<SparklesIcon class="h-16 w-16 text-purple-300 mx-auto mb-4" />
|
||||
<p class="text-xl text-gray-500">还没有开始冒险呢,快去创作第一个故事吧!</p>
|
||||
<p class="text-xl text-gray-500">还没有开始冒险呢,先来创作第一个故事吧!</p>
|
||||
</div>
|
||||
|
||||
<!-- 时间轴内容 -->
|
||||
|
||||
@@ -9,6 +9,10 @@ import EmptyState from '../components/ui/EmptyState.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import type { GenerationOpsSummary, GenerationProviderAnalytics } from '../types/generation'
|
||||
import type { VoiceSessionAnalytics, VoiceSessionSummary } from '../types/voiceSession'
|
||||
import {
|
||||
getVoiceSessionNextAction,
|
||||
getVoiceSessionNextStep,
|
||||
} from '../utils/voiceSession'
|
||||
import {
|
||||
getAssetStatusMeta,
|
||||
getGenerationStatusMeta,
|
||||
@@ -172,31 +176,16 @@ function continueActiveVoiceSession() {
|
||||
goToVoiceStudio()
|
||||
return
|
||||
}
|
||||
if (activeVoiceSession.value.latest_requires_confirmation) {
|
||||
goToVoiceStudio({
|
||||
reason: 'pending_confirmation',
|
||||
sessionId: activeVoiceSession.value.id,
|
||||
focus: 'confirmation',
|
||||
})
|
||||
const action = getVoiceSessionNextAction(activeVoiceSession.value)
|
||||
if (action.storyId) {
|
||||
router.push(`/story/${action.storyId}`)
|
||||
return
|
||||
}
|
||||
if (activeVoiceSession.value.latest_safety_message) {
|
||||
goToVoiceStudio({
|
||||
reason: 'safety_intervention',
|
||||
sessionId: activeVoiceSession.value.id,
|
||||
focus: 'safety',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (activeVoiceSession.value.attention_reasons.includes('failed_turn')) {
|
||||
goToVoiceStudio({
|
||||
reason: 'failed_turn',
|
||||
sessionId: activeVoiceSession.value.id,
|
||||
focus: 'failed',
|
||||
})
|
||||
return
|
||||
}
|
||||
goToVoiceStudio({ sessionId: activeVoiceSession.value.id })
|
||||
goToVoiceStudio({
|
||||
reason: action.reason,
|
||||
sessionId: activeVoiceSession.value.id,
|
||||
focus: action.focus,
|
||||
})
|
||||
}
|
||||
|
||||
function getStoryLink(story: StoryItem) {
|
||||
@@ -316,10 +305,21 @@ watch([selectedWindow, selectedCapability, selectedVoiceWindow], () => {
|
||||
>
|
||||
最近一轮触发了儿童内容安全兜底,建议回到工作台查看详细记录。
|
||||
</p>
|
||||
<div
|
||||
class="mt-3 rounded-xl border px-3 py-3 text-sm leading-6"
|
||||
:class="getVoiceSessionNextStep(activeVoiceSession).toneClass"
|
||||
>
|
||||
<div class="font-medium">
|
||||
建议动作:{{ getVoiceSessionNextStep(activeVoiceSession).label }}
|
||||
</div>
|
||||
<div class="mt-1 opacity-90">
|
||||
{{ getVoiceSessionNextStep(activeVoiceSession).description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BaseButton @click="continueActiveVoiceSession">
|
||||
<SparklesIcon class="h-5 w-5 mr-2" />
|
||||
继续语音共创
|
||||
{{ getVoiceSessionNextAction(activeVoiceSession).label }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
@@ -19,6 +19,10 @@ import BaseTextarea from '../components/ui/BaseTextarea.vue'
|
||||
import GenerationTrace from '../components/GenerationTrace.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import EmptyState from '../components/ui/EmptyState.vue'
|
||||
import {
|
||||
getVoiceSessionNextAction,
|
||||
getVoiceSessionNextStep,
|
||||
} from '../utils/voiceSession'
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
BookOpenIcon,
|
||||
@@ -40,6 +44,24 @@ interface StoryUniverse {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface VoiceStoryStateSummary {
|
||||
premise: string | null
|
||||
latestDirection: string | null
|
||||
latestSegment: string | null
|
||||
segmentCount: number
|
||||
safetyFlags: string[]
|
||||
coverPromptReady: boolean
|
||||
}
|
||||
|
||||
interface VoiceStoryActionStep {
|
||||
key: 'confirm' | 'continue' | 'finalize' | 'review_result'
|
||||
title: string
|
||||
description: string
|
||||
status: 'done' | 'current' | 'upcoming'
|
||||
contextLabel?: string
|
||||
contextValue?: string | null
|
||||
}
|
||||
|
||||
type SessionFilter = 'active' | 'attention' | 'recent'
|
||||
type AttentionReasonFilter = 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn'
|
||||
type VoiceStudioFocusTarget = 'confirmation' | 'safety' | 'failed' | 'text'
|
||||
@@ -80,6 +102,7 @@ let recordingTimer: number | null = null
|
||||
let recordingStartedAt = 0
|
||||
let sessionPollTimer: number | null = null
|
||||
let autoAdvanceNoticeTimer: number | null = null
|
||||
let attentionCompletionNoticeTimer: number | null = null
|
||||
|
||||
const recordedBlob = ref<Blob | null>(null)
|
||||
const recordedAudioUrl = ref<string | null>(null)
|
||||
@@ -91,21 +114,7 @@ const universeOptions = computed(() =>
|
||||
universes.value.map((universe) => ({ value: universe.id, label: universe.name })),
|
||||
)
|
||||
const filteredSessions = computed(() => {
|
||||
switch (sessionFilter.value) {
|
||||
case 'active':
|
||||
return sessions.value.filter((session) => session.can_continue)
|
||||
case 'attention':
|
||||
return sessions.value.filter(
|
||||
(session) =>
|
||||
sessionNeedsAttention(session)
|
||||
&& (
|
||||
attentionReasonFilter.value === 'all'
|
||||
|| session.attention_reasons.includes(attentionReasonFilter.value)
|
||||
),
|
||||
)
|
||||
default:
|
||||
return sessions.value
|
||||
}
|
||||
return resolveDisplayedSessions(sessions.value)
|
||||
})
|
||||
|
||||
const activeTurnList = computed(() => activeSession.value?.recent_turns ?? [])
|
||||
@@ -129,6 +138,127 @@ const finalStorySummary = computed(() => {
|
||||
const value = activeSession.value?.story_state?.final_summary
|
||||
return typeof value === 'string' ? value : null
|
||||
})
|
||||
const storyStateSummary = computed<VoiceStoryStateSummary | null>(() => {
|
||||
if (!activeSession.value) return null
|
||||
const storyState = activeSession.value.story_state ?? {}
|
||||
const narrativeSegments = Array.isArray(storyState.narrative_segments)
|
||||
? storyState.narrative_segments.filter((segment): segment is string => typeof segment === 'string' && segment.trim().length > 0)
|
||||
: []
|
||||
const safetyFlags = Array.isArray(storyState.safety_flags)
|
||||
? storyState.safety_flags.filter((flag): flag is string => typeof flag === 'string' && flag.trim().length > 0)
|
||||
: []
|
||||
|
||||
return {
|
||||
premise: typeof storyState.premise === 'string' && storyState.premise.trim().length > 0
|
||||
? storyState.premise.trim()
|
||||
: null,
|
||||
latestDirection: typeof storyState.latest_direction === 'string' && storyState.latest_direction.trim().length > 0
|
||||
? storyState.latest_direction.trim()
|
||||
: null,
|
||||
latestSegment: narrativeSegments.length > 0 ? narrativeSegments[narrativeSegments.length - 1] : null,
|
||||
segmentCount: narrativeSegments.length,
|
||||
safetyFlags,
|
||||
coverPromptReady: typeof storyState.cover_prompt === 'string' && storyState.cover_prompt.trim().length > 0,
|
||||
}
|
||||
})
|
||||
const storyActionSteps = computed<VoiceStoryActionStep[]>(() => {
|
||||
if (!activeSession.value || !storyStateSummary.value) return []
|
||||
|
||||
const steps: VoiceStoryActionStep[] = []
|
||||
const isFinalized = Boolean(activeSession.value.final_story_id)
|
||||
|
||||
steps.push({
|
||||
key: 'confirm',
|
||||
title: '确认这一轮理解',
|
||||
description: hasPendingConfirmation.value
|
||||
? '这一轮还在等待家长确认,建议先确认系统理解后再继续。'
|
||||
: activeSession.value.total_turns > 0
|
||||
? '当前没有待确认回合,系统已经可以按最近理解继续往下讲。'
|
||||
: '等第一轮输入进来后,这里会显示系统是否需要家长确认。',
|
||||
status: hasPendingConfirmation.value
|
||||
? 'current'
|
||||
: activeSession.value.total_turns > 0
|
||||
? 'done'
|
||||
: 'upcoming',
|
||||
contextLabel: hasPendingConfirmation.value ? '当前系统理解' : undefined,
|
||||
contextValue: hasPendingConfirmation.value
|
||||
? activeSession.value.latest_understanding_summary || activeSession.value.latest_confirmation_message
|
||||
: null,
|
||||
})
|
||||
|
||||
steps.push({
|
||||
key: 'continue',
|
||||
title: '继续推进故事',
|
||||
description: activeSession.value.latest_safety_message
|
||||
? '建议先换一种更温和的表达,再继续补下一段故事。'
|
||||
: storyStateSummary.value.segmentCount > 0
|
||||
? `当前已经生成 ${storyStateSummary.value.segmentCount} 段故事,可以继续补下一轮或修正走向。`
|
||||
: '第一段故事生成后,这里会提示可以继续补下一轮。',
|
||||
status: isFinalized
|
||||
? 'done'
|
||||
: !hasPendingConfirmation.value && activeSession.value.can_continue
|
||||
? 'current'
|
||||
: storyStateSummary.value.segmentCount > 0
|
||||
? 'done'
|
||||
: 'upcoming',
|
||||
contextLabel:
|
||||
!isFinalized && !hasPendingConfirmation.value && activeSession.value.can_continue
|
||||
? activeSession.value.latest_safety_message
|
||||
? '建议改写方向'
|
||||
: storyStateSummary.value.latestDirection
|
||||
? '最新改写方向'
|
||||
: storyStateSummary.value.latestSegment
|
||||
? '最近一段故事'
|
||||
: undefined
|
||||
: undefined,
|
||||
contextValue:
|
||||
!isFinalized && !hasPendingConfirmation.value && activeSession.value.can_continue
|
||||
? activeSession.value.latest_safety_message
|
||||
|| storyStateSummary.value.latestDirection
|
||||
|| truncateNarrative(storyStateSummary.value.latestSegment, 110)
|
||||
: null,
|
||||
})
|
||||
|
||||
steps.push({
|
||||
key: 'finalize',
|
||||
title: '保存当前版本',
|
||||
description: isFinalized
|
||||
? '当前版本已经沉淀为正式故事。'
|
||||
: activeSession.value.can_finalize
|
||||
? '当前内容已经足够完整,可以随时保存成正式故事。'
|
||||
: '再多完成一段稳定内容后,就可以保存为正式故事。',
|
||||
status: isFinalized
|
||||
? 'done'
|
||||
: activeSession.value.can_finalize
|
||||
? 'current'
|
||||
: 'upcoming',
|
||||
contextLabel:
|
||||
!isFinalized && activeSession.value.can_finalize
|
||||
? '保存后内容预览'
|
||||
: undefined,
|
||||
contextValue:
|
||||
!isFinalized && activeSession.value.can_finalize
|
||||
? finalStorySummary.value || truncateNarrative(storyStateSummary.value.latestSegment, 110)
|
||||
: null,
|
||||
})
|
||||
|
||||
steps.push({
|
||||
key: 'review_result',
|
||||
title: '查看正式故事与后续资源',
|
||||
description: isFinalized
|
||||
? '现在可以查看正式故事回看结果,也可以等待封面等后续资源继续补齐。'
|
||||
: '保存完成后,这里会接上正式故事查看和资源补全过程。',
|
||||
status: isFinalized ? 'current' : 'upcoming',
|
||||
contextLabel: isFinalized ? '正式故事状态' : undefined,
|
||||
contextValue: isFinalized
|
||||
? finalStorySummary.value
|
||||
? `故事 #${activeSession.value.final_story_id} · ${finalStorySummary.value}`
|
||||
: `故事 #${activeSession.value.final_story_id}`
|
||||
: null,
|
||||
})
|
||||
|
||||
return steps
|
||||
})
|
||||
const finalStoryId = computed(() => activeSession.value?.final_story_id ?? null)
|
||||
const finalStoryHasAssetWork = computed(() => Boolean(finalStoryId.value))
|
||||
const turnSuccessRateLabel = computed(() => {
|
||||
@@ -166,6 +296,10 @@ const autoAdvanceNotice = ref<{
|
||||
toTitle: string
|
||||
reasonLabel: string
|
||||
} | null>(null)
|
||||
const attentionCompletionNotice = ref<{
|
||||
completedReason: AttentionReasonFilter
|
||||
completedReasonLabel: string
|
||||
} | null>(null)
|
||||
|
||||
function formatSessionStatus(status: string) {
|
||||
switch (status) {
|
||||
@@ -238,6 +372,82 @@ function formatConfidence(value: number | null | undefined) {
|
||||
return `${Math.round(value * 100)}%`
|
||||
}
|
||||
|
||||
function truncateNarrative(value: string | null, maxLength = 90) {
|
||||
if (!value) return null
|
||||
const normalized = value.replace(/\s+/g, ' ').trim()
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized
|
||||
}
|
||||
return `${normalized.slice(0, maxLength).trim()}...`
|
||||
}
|
||||
|
||||
function getStoryActionStepClass(status: VoiceStoryActionStep['status']) {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return {
|
||||
badge: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
dot: 'bg-emerald-500',
|
||||
}
|
||||
case 'current':
|
||||
return {
|
||||
badge: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
dot: 'bg-amber-500',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
badge: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
dot: 'bg-gray-300',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToElement(elementId: string) {
|
||||
void nextTick(() => {
|
||||
document.getElementById(elementId)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function handleStoryActionStep(step: VoiceStoryActionStep) {
|
||||
if (step.status !== 'current') return
|
||||
|
||||
switch (step.key) {
|
||||
case 'confirm':
|
||||
scrollToElement('voice-attention-confirmation-card')
|
||||
return
|
||||
case 'continue':
|
||||
jumpToTextTurnComposer('')
|
||||
return
|
||||
case 'finalize':
|
||||
void finalizeSession()
|
||||
return
|
||||
case 'review_result':
|
||||
viewFinalStory()
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function getStoryActionStepButtonLabel(step: VoiceStoryActionStep) {
|
||||
if (step.status !== 'current') return null
|
||||
|
||||
switch (step.key) {
|
||||
case 'confirm':
|
||||
return '确认'
|
||||
case 'continue':
|
||||
return activeSession.value?.latest_safety_message ? '改写' : '继续'
|
||||
case 'finalize':
|
||||
return '保存当前版本'
|
||||
case 'review_result':
|
||||
return '查看正式故事'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatAnalyticsWindowLabel(windowDays: number | null | undefined) {
|
||||
if (typeof windowDays === 'number') {
|
||||
return `最近 ${windowDays} 天`
|
||||
@@ -274,6 +484,59 @@ function formatAttentionReason(reason: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionUpdatedAtValue(session: VoiceSessionSummary) {
|
||||
const value = Date.parse(session.updated_at)
|
||||
return Number.isNaN(value) ? 0 : value
|
||||
}
|
||||
|
||||
function getAttentionPriority(session: VoiceSessionSummary) {
|
||||
if (session.attention_reasons.includes('pending_confirmation')) {
|
||||
return 0
|
||||
}
|
||||
if (session.attention_reasons.includes('failed_turn')) {
|
||||
return 1
|
||||
}
|
||||
if (session.attention_reasons.includes('safety_intervention')) {
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
function sortDisplayedSessions(list: VoiceSessionSummary[]) {
|
||||
return [...list].sort((left, right) => {
|
||||
if (sessionFilter.value === 'attention') {
|
||||
const priorityDiff = getAttentionPriority(left) - getAttentionPriority(right)
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff
|
||||
}
|
||||
}
|
||||
return getSessionUpdatedAtValue(right) - getSessionUpdatedAtValue(left)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveDisplayedSessions(sourceSessions: VoiceSessionSummary[]) {
|
||||
let visibleSessions: VoiceSessionSummary[]
|
||||
switch (sessionFilter.value) {
|
||||
case 'active':
|
||||
visibleSessions = sourceSessions.filter((session) => session.can_continue)
|
||||
break
|
||||
case 'attention':
|
||||
visibleSessions = sourceSessions.filter(
|
||||
(session) =>
|
||||
sessionNeedsAttention(session)
|
||||
&& (
|
||||
attentionReasonFilter.value === 'all'
|
||||
|| session.attention_reasons.includes(attentionReasonFilter.value)
|
||||
),
|
||||
)
|
||||
break
|
||||
default:
|
||||
visibleSessions = sourceSessions
|
||||
break
|
||||
}
|
||||
return sortDisplayedSessions(visibleSessions)
|
||||
}
|
||||
|
||||
function parseSessionFilter(value: unknown): SessionFilter | null {
|
||||
if (value === 'active' || value === 'attention' || value === 'recent') {
|
||||
return value
|
||||
@@ -302,6 +565,7 @@ function parseFocusTarget(value: unknown): VoiceStudioFocusTarget | null {
|
||||
|
||||
function applyRouteState() {
|
||||
suppressAutoAdvanceNotice.value = true
|
||||
clearAttentionCompletionNotice()
|
||||
sessionFilter.value = parseSessionFilter(route.query.filter) ?? 'active'
|
||||
attentionReasonFilter.value = parseAttentionReasonFilter(route.query.reason) ?? 'all'
|
||||
pendingFocusTarget.value = parseFocusTarget(route.query.focus)
|
||||
@@ -414,6 +678,14 @@ function clearAutoAdvanceNotice() {
|
||||
autoAdvanceNotice.value = null
|
||||
}
|
||||
|
||||
function clearAttentionCompletionNotice() {
|
||||
if (attentionCompletionNoticeTimer) {
|
||||
window.clearTimeout(attentionCompletionNoticeTimer)
|
||||
attentionCompletionNoticeTimer = null
|
||||
}
|
||||
attentionCompletionNotice.value = null
|
||||
}
|
||||
|
||||
function showAutoAdvanceNotice(fromTitle: string, toTitle: string) {
|
||||
clearAutoAdvanceNotice()
|
||||
autoAdvanceNotice.value = {
|
||||
@@ -430,6 +702,18 @@ function showAutoAdvanceNotice(fromTitle: string, toTitle: string) {
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function showAttentionCompletionNotice(completedReason: AttentionReasonFilter) {
|
||||
clearAttentionCompletionNotice()
|
||||
attentionCompletionNotice.value = {
|
||||
completedReason,
|
||||
completedReasonLabel: formatAttentionReasonTitle(completedReason),
|
||||
}
|
||||
attentionCompletionNoticeTimer = window.setTimeout(() => {
|
||||
attentionCompletionNotice.value = null
|
||||
attentionCompletionNoticeTimer = null
|
||||
}, 6000)
|
||||
}
|
||||
|
||||
async function fetchProfiles() {
|
||||
if (!userStore.user) return
|
||||
const data = await api.get<{ profiles: ChildProfile[] }>('/api/profiles')
|
||||
@@ -458,20 +742,21 @@ async function loadSessions() {
|
||||
try {
|
||||
const previousActiveSession = activeSession.value
|
||||
sessions.value = await api.get<VoiceSessionSummary[]>(buildVoiceSessionListPath())
|
||||
const displayedSessions = resolveDisplayedSessions(sessions.value)
|
||||
if (
|
||||
(requestedSessionId.value || pendingFocusTarget.value)
|
||||
&& requestedSessionId.value
|
||||
&& !sessions.value.some((item) => item.id === requestedSessionId.value)
|
||||
&& !displayedSessions.some((item) => item.id === requestedSessionId.value)
|
||||
) {
|
||||
void syncVoiceStudioRouteState()
|
||||
}
|
||||
const preferredSession = (
|
||||
requestedSessionId.value
|
||||
? sessions.value.find((item) => item.id === requestedSessionId.value)
|
||||
? displayedSessions.find((item) => item.id === requestedSessionId.value)
|
||||
: null
|
||||
) ?? sessions.value.find((item) => item.can_continue) ?? sessions.value[0]
|
||||
) ?? displayedSessions.find((item) => item.can_continue) ?? displayedSessions[0]
|
||||
const currentSessionStillVisible = activeSession.value
|
||||
? sessions.value.some((item) => item.id === activeSession.value?.id)
|
||||
? displayedSessions.some((item) => item.id === activeSession.value?.id)
|
||||
: false
|
||||
if (
|
||||
!activeSession.value
|
||||
@@ -497,6 +782,14 @@ async function loadSessions() {
|
||||
}
|
||||
await loadSessionDetail(preferredSession.id)
|
||||
} else if (sessionFilter.value !== 'recent') {
|
||||
if (
|
||||
sessionFilter.value === 'attention'
|
||||
&& previousActiveSession
|
||||
&& !currentSessionStillVisible
|
||||
&& !suppressAutoAdvanceNotice.value
|
||||
) {
|
||||
showAttentionCompletionNotice(attentionReasonFilter.value)
|
||||
}
|
||||
activeSession.value = null
|
||||
}
|
||||
}
|
||||
@@ -812,12 +1105,23 @@ function viewFinalStory() {
|
||||
router.push(`/story/${activeSession.value.final_story_id}`)
|
||||
}
|
||||
|
||||
async function openSessionQuickAction(session: VoiceSessionSummary) {
|
||||
const action = getVoiceSessionNextAction(session)
|
||||
if (action.storyId) {
|
||||
router.push(`/story/${action.storyId}`)
|
||||
return
|
||||
}
|
||||
pendingFocusTarget.value = action.focus ?? null
|
||||
await loadSessionDetail(session.id)
|
||||
}
|
||||
|
||||
function setAnalyticsWindow(value: '7' | '30' | 'all') {
|
||||
analyticsWindow.value = value
|
||||
}
|
||||
|
||||
function setSessionFilter(value: SessionFilter) {
|
||||
suppressAutoAdvanceNotice.value = true
|
||||
clearAttentionCompletionNotice()
|
||||
sessionFilter.value = value
|
||||
if (value !== 'attention') {
|
||||
attentionReasonFilter.value = 'all'
|
||||
@@ -840,6 +1144,7 @@ function setAttentionReasonFilter(
|
||||
value: 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn',
|
||||
) {
|
||||
suppressAutoAdvanceNotice.value = true
|
||||
clearAttentionCompletionNotice()
|
||||
attentionReasonFilter.value = value
|
||||
void syncVoiceStudioRouteState()
|
||||
}
|
||||
@@ -852,6 +1157,17 @@ function focusAttentionReason(
|
||||
void syncVoiceStudioRouteState()
|
||||
}
|
||||
|
||||
function getSuggestedAttentionReasons(
|
||||
completedReason: AttentionReasonFilter,
|
||||
): Exclude<AttentionReasonFilter, 'all'>[] {
|
||||
const orderedReasons: Exclude<AttentionReasonFilter, 'all'>[] = [
|
||||
'pending_confirmation',
|
||||
'safety_intervention',
|
||||
'failed_turn',
|
||||
]
|
||||
return orderedReasons.filter((reason) => reason !== completedReason)
|
||||
}
|
||||
|
||||
async function focusRequestedTarget(sessionId: string) {
|
||||
const target = pendingFocusTarget.value
|
||||
if (!target) return
|
||||
@@ -960,6 +1276,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
clearRecordedAudio()
|
||||
clearAutoAdvanceNotice()
|
||||
clearAttentionCompletionNotice()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1130,58 +1447,82 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<button
|
||||
<div
|
||||
v-for="session in filteredSessions"
|
||||
:key="session.id"
|
||||
type="button"
|
||||
class="w-full rounded-2xl border px-4 py-3 text-left transition-all"
|
||||
class="w-full rounded-2xl border transition-all"
|
||||
:class="activeSession?.id === session.id
|
||||
? 'border-purple-300 bg-purple-50'
|
||||
: 'border-gray-100 bg-white hover:border-gray-300'"
|
||||
@click="loadSessionDetail(session.id)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium text-gray-900">
|
||||
{{ session.working_title || '未命名语音会话' }}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="reason in session.attention_reasons"
|
||||
:key="`${session.id}-${reason}`"
|
||||
class="rounded-full px-2.5 py-1 text-[11px] font-medium"
|
||||
:class="formatAttentionReason(reason).className"
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-4 pt-3 text-left"
|
||||
@click="loadSessionDetail(session.id)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium text-gray-900">
|
||||
{{ session.working_title || '未命名语音会话' }}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="reason in session.attention_reasons"
|
||||
:key="`${session.id}-${reason}`"
|
||||
class="rounded-full px-2.5 py-1 text-[11px] font-medium"
|
||||
:class="formatAttentionReason(reason).className"
|
||||
>
|
||||
{{ formatAttentionReason(reason).label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!session.attention_reasons.length && session.can_continue"
|
||||
class="rounded-full bg-amber-100 px-2.5 py-1 text-[11px] font-medium text-amber-700"
|
||||
>
|
||||
可继续
|
||||
</span>
|
||||
<span
|
||||
v-if="session.final_story_id"
|
||||
class="rounded-full bg-emerald-100 px-2.5 py-1 text-[11px] font-medium text-emerald-700"
|
||||
>
|
||||
已保存
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{{ formatSessionStatus(session.status) }} · {{ session.total_turns }} 轮
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 rounded-xl border px-3 py-2 text-xs leading-5"
|
||||
:class="getVoiceSessionNextStep(session).toneClass"
|
||||
>
|
||||
{{ formatAttentionReason(reason).label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!session.attention_reasons.length && session.can_continue"
|
||||
class="rounded-full bg-amber-100 px-2.5 py-1 text-[11px] font-medium text-amber-700"
|
||||
<div class="font-medium">
|
||||
下一步:{{ getVoiceSessionNextStep(session).label }}
|
||||
</div>
|
||||
<div class="mt-1 opacity-90">
|
||||
{{ getVoiceSessionNextStep(session).description }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="session.latest_understanding_summary || session.latest_user_transcript || session.latest_safety_message"
|
||||
class="mt-2 line-clamp-2 text-xs leading-5 text-gray-500"
|
||||
>
|
||||
可继续
|
||||
</span>
|
||||
<span
|
||||
v-if="session.final_story_id"
|
||||
class="rounded-full bg-emerald-100 px-2.5 py-1 text-[11px] font-medium text-emerald-700"
|
||||
>
|
||||
已保存
|
||||
</span>
|
||||
{{ session.latest_understanding_summary || session.latest_safety_message || session.latest_user_transcript }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{{ formatSessionStatus(session.status) }} · {{ session.total_turns }} 轮
|
||||
</div>
|
||||
<div
|
||||
v-if="session.latest_understanding_summary || session.latest_user_transcript || session.latest_safety_message"
|
||||
class="mt-2 line-clamp-2 text-xs leading-5 text-gray-500"
|
||||
>
|
||||
{{ session.latest_understanding_summary || session.latest_safety_message || session.latest_user_transcript }}
|
||||
<div class="text-right text-xs text-gray-400">
|
||||
{{ formatDate(session.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right text-xs text-gray-400">
|
||||
{{ formatDate(session.updated_at) }}
|
||||
</div>
|
||||
</button>
|
||||
<div class="flex justify-end px-4 pb-3">
|
||||
<BaseButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="openSessionQuickAction(session)"
|
||||
>
|
||||
{{ getVoiceSessionNextAction(session).label }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</div>
|
||||
@@ -1213,6 +1554,44 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard
|
||||
v-if="attentionCompletionNotice"
|
||||
class="border border-emerald-100 bg-emerald-50 text-emerald-800"
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="text-sm leading-6">
|
||||
{{ attentionCompletionNotice.completedReasonLabel }} 已处理完。
|
||||
你可以继续切到下一类 attention,或先回到最近会话总览。
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="reason in getSuggestedAttentionReasons(attentionCompletionNotice.completedReason)"
|
||||
:key="`completion-${reason}`"
|
||||
type="button"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="formatAttentionReason(reason).className"
|
||||
@click="focusAttentionReason(reason)"
|
||||
>
|
||||
看{{ formatAttentionReason(reason).label }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-600 transition-colors hover:border-gray-400"
|
||||
@click="setSessionFilter('recent')"
|
||||
>
|
||||
回到最近全部
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-1 text-emerald-500 transition-colors hover:bg-emerald-100 hover:text-emerald-700"
|
||||
@click="clearAttentionCompletionNotice"
|
||||
>
|
||||
<XMarkIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard v-if="voiceAnalytics" class="border border-slate-100 bg-white/90">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -1378,6 +1757,166 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
v-if="storyStateSummary"
|
||||
class="rounded-2xl border border-slate-100 bg-slate-50/80 p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900">当前故事状态</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
这块是把会话里的 `story_state` 翻成家长可读摘要,方便确认“故事现在讲到哪了”。
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-xs font-medium"
|
||||
:class="activeSession.can_finalize
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: hasPendingConfirmation
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: activeSession.can_continue
|
||||
? 'bg-sky-100 text-sky-700'
|
||||
: 'bg-gray-100 text-gray-700'"
|
||||
>
|
||||
{{ activeSession.can_finalize
|
||||
? '当前版本可保存'
|
||||
: hasPendingConfirmation
|
||||
? '需先确认这一轮'
|
||||
: activeSession.can_continue
|
||||
? '可以继续讲下一轮'
|
||||
: '先查看当前状态' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-xl border border-white bg-white px-4 py-3">
|
||||
<div class="text-xs text-gray-500">当前主题</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-800">
|
||||
{{ storyStateSummary.premise || '还在等待第一轮主题' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white bg-white px-4 py-3">
|
||||
<div class="text-xs text-gray-500">最新改写方向</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-800">
|
||||
{{ storyStateSummary.latestDirection || activeSession.latest_user_transcript || '暂未收到新的剧情修正' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white bg-white px-4 py-3">
|
||||
<div class="text-xs text-gray-500">已生成段数</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-800">
|
||||
{{ storyStateSummary.segmentCount }} 段
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white bg-white px-4 py-3">
|
||||
<div class="text-xs text-gray-500">最近意图</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-800">
|
||||
{{ formatIntent(activeSession.latest_detected_intent) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{{ storyStateSummary.coverPromptReady ? '封面提示已准备' : '封面提示会在后续补齐' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="storyStateSummary.latestSegment"
|
||||
class="mt-4 rounded-xl border border-white bg-white px-4 py-3"
|
||||
>
|
||||
<div class="text-xs text-gray-500">最近一段故事</div>
|
||||
<div class="mt-1 text-sm leading-6 text-gray-700">
|
||||
{{ truncateNarrative(storyStateSummary.latestSegment, 140) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="storyActionSteps.length"
|
||||
class="mt-4 rounded-xl border border-white bg-white px-4 py-4"
|
||||
>
|
||||
<div class="text-xs text-gray-500">当前可执行路径</div>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="(step, index) in storyActionSteps"
|
||||
:key="step.key"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<div class="mt-1 flex flex-col items-center">
|
||||
<span
|
||||
class="h-2.5 w-2.5 rounded-full"
|
||||
:class="getStoryActionStepClass(step.status).dot"
|
||||
></span>
|
||||
<span
|
||||
v-if="index < storyActionSteps.length - 1"
|
||||
class="mt-1 h-8 w-px bg-gray-200"
|
||||
></span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-800">{{ step.title }}</span>
|
||||
<span
|
||||
class="rounded-full border px-2.5 py-0.5 text-[11px] font-medium"
|
||||
:class="getStoryActionStepClass(step.status).badge"
|
||||
>
|
||||
{{ step.status === 'done' ? '已完成' : step.status === 'current' ? '当前建议' : '后续步骤' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm leading-6 text-gray-600">
|
||||
{{ step.description }}
|
||||
</div>
|
||||
<div
|
||||
v-if="step.status === 'current' && step.contextValue"
|
||||
class="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-3"
|
||||
>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ step.contextLabel }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm leading-6 text-slate-700">
|
||||
{{ step.contextValue }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="getStoryActionStepButtonLabel(step)"
|
||||
class="mt-3"
|
||||
>
|
||||
<BaseButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@click="handleStoryActionStep(step)"
|
||||
:disabled="
|
||||
sendingTurn
|
||||
|| finalizing
|
||||
|| (step.key === 'finalize' && !activeSession.can_finalize)
|
||||
|| (step.key === 'review_result' && !activeSession.final_story_id)
|
||||
"
|
||||
>
|
||||
{{ getStoryActionStepButtonLabel(step) }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeSession.latest_understanding_summary"
|
||||
class="mt-3 rounded-xl border border-dashed border-slate-200 bg-white px-4 py-3"
|
||||
>
|
||||
<div class="text-xs text-gray-500">当前系统理解</div>
|
||||
<div class="mt-1 text-sm leading-6 text-slate-700">
|
||||
{{ activeSession.latest_understanding_summary }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="storyStateSummary.safetyFlags.length"
|
||||
class="mt-3 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-rose-700"
|
||||
>
|
||||
<div class="text-xs font-medium">当前故事仍带有安全标记</div>
|
||||
<div class="mt-1 text-sm">
|
||||
{{ storyStateSummary.safetyFlags.join(' / ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeSession.latest_requires_confirmation && latestPendingConfirmationTurn"
|
||||
id="voice-attention-confirmation-card"
|
||||
@@ -1500,7 +2039,7 @@ onBeforeUnmount(() => {
|
||||
<div class="mt-3">
|
||||
<BaseButton size="sm" variant="secondary" @click="viewFinalStory">
|
||||
<BookOpenIcon class="h-4 w-4" />
|
||||
打开正式故事
|
||||
查看正式故事
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1735,7 +2274,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
|
||||
<div class="rounded-2xl border border-gray-100 bg-white p-4">
|
||||
<h3 class="font-semibold text-gray-900">故事状态快照</h3>
|
||||
<h3 class="font-semibold text-gray-900">原始状态快照(调试)</h3>
|
||||
<pre class="mt-4 overflow-x-auto rounded-xl bg-gray-950 p-4 text-xs leading-6 text-emerald-200">{{ JSON.stringify(activeSession.story_state, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user