feat: refine voice studio attention workflow
This commit is contained in:
@@ -37,6 +37,9 @@ interface StoryItem {
|
||||
last_error: string | null
|
||||
}
|
||||
|
||||
type VoiceAttentionReason = 'pending_confirmation' | 'safety_intervention' | 'failed_turn'
|
||||
type VoiceStudioFocusTarget = 'confirmation' | 'safety' | 'failed' | 'text'
|
||||
|
||||
const router = useRouter()
|
||||
const stories = ref<StoryItem[]>([])
|
||||
const providerAnalytics = ref<GenerationProviderAnalytics | null>(null)
|
||||
@@ -47,6 +50,7 @@ const loading = ref(true)
|
||||
const error = ref('')
|
||||
const showCreateModal = ref(false)
|
||||
const selectedWindow = ref<'7' | '30' | 'all'>('30')
|
||||
const selectedVoiceWindow = ref<'7' | '30' | 'all'>('30')
|
||||
const selectedCapability = ref<'all' | 'text' | 'image' | 'tts' | 'storybook'>('all')
|
||||
|
||||
const readableCount = computed(() =>
|
||||
@@ -72,6 +76,9 @@ const voiceFinalizeRate = computed(() => {
|
||||
if (!voiceAnalytics.value) return null
|
||||
return Math.round(voiceAnalytics.value.finalize_conversion_rate * 100)
|
||||
})
|
||||
const voiceAnalyticsWindowLabel = computed(() =>
|
||||
formatWindowLabel(voiceAnalytics.value?.window_days ?? null),
|
||||
)
|
||||
|
||||
function buildProviderAnalyticsPath() {
|
||||
const params = new URLSearchParams()
|
||||
@@ -85,6 +92,13 @@ function buildProviderAnalyticsPath() {
|
||||
return `/api/generations/provider-analytics${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
||||
function buildVoiceAnalyticsPath() {
|
||||
if (selectedVoiceWindow.value === 'all') {
|
||||
return '/api/voice-sessions/analytics'
|
||||
}
|
||||
return `/api/voice-sessions/analytics?days=${selectedVoiceWindow.value}`
|
||||
}
|
||||
|
||||
async function fetchStories() {
|
||||
try {
|
||||
const [storyList, analytics, ops, activeSession, voiceOverview] = await Promise.all([
|
||||
@@ -92,7 +106,7 @@ async function fetchStories() {
|
||||
api.get<GenerationProviderAnalytics>(buildProviderAnalyticsPath()),
|
||||
api.get<GenerationOpsSummary>('/api/generations/ops-summary'),
|
||||
api.get<VoiceSessionSummary | null>('/api/voice-sessions/active').catch(() => null),
|
||||
api.get<VoiceSessionAnalytics>('/api/voice-sessions/analytics?days=30').catch(() => null),
|
||||
api.get<VoiceSessionAnalytics>(buildVoiceAnalyticsPath()).catch(() => null),
|
||||
])
|
||||
stories.value = storyList
|
||||
providerAnalytics.value = analytics
|
||||
@@ -123,12 +137,66 @@ function formatDate(dateStr: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function formatWindowLabel(windowDays: number | null | undefined) {
|
||||
if (typeof windowDays === 'number') {
|
||||
return `最近 ${windowDays} 天`
|
||||
}
|
||||
return '全部历史'
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
function goToVoiceStudio() {
|
||||
router.push('/voice-studio')
|
||||
function goToVoiceStudio(options?: {
|
||||
reason?: VoiceAttentionReason
|
||||
sessionId?: string
|
||||
focus?: VoiceStudioFocusTarget
|
||||
}) {
|
||||
const query: Record<string, string> = {}
|
||||
if (options?.reason) {
|
||||
query.filter = 'attention'
|
||||
query.reason = options.reason
|
||||
}
|
||||
if (options?.sessionId) {
|
||||
query.session = options.sessionId
|
||||
}
|
||||
if (options?.focus) {
|
||||
query.focus = options.focus
|
||||
}
|
||||
router.push({ path: '/voice-studio', query })
|
||||
}
|
||||
|
||||
function continueActiveVoiceSession() {
|
||||
if (!activeVoiceSession.value) {
|
||||
goToVoiceStudio()
|
||||
return
|
||||
}
|
||||
if (activeVoiceSession.value.latest_requires_confirmation) {
|
||||
goToVoiceStudio({
|
||||
reason: 'pending_confirmation',
|
||||
sessionId: activeVoiceSession.value.id,
|
||||
focus: 'confirmation',
|
||||
})
|
||||
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 })
|
||||
}
|
||||
|
||||
function getStoryLink(story: StoryItem) {
|
||||
@@ -160,6 +228,10 @@ function setWindow(value: '7' | '30' | 'all') {
|
||||
selectedWindow.value = value
|
||||
}
|
||||
|
||||
function setVoiceWindow(value: '7' | '30' | 'all') {
|
||||
selectedVoiceWindow.value = value
|
||||
}
|
||||
|
||||
function setCapability(value: 'all' | 'text' | 'image' | 'tts' | 'storybook') {
|
||||
selectedCapability.value = value
|
||||
}
|
||||
@@ -173,7 +245,7 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch([selectedWindow, selectedCapability], () => {
|
||||
watch([selectedWindow, selectedCapability, selectedVoiceWindow], () => {
|
||||
void fetchStories()
|
||||
})
|
||||
</script>
|
||||
@@ -245,7 +317,7 @@ watch([selectedWindow, selectedCapability], () => {
|
||||
最近一轮触发了儿童内容安全兜底,建议回到工作台查看详细记录。
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton @click="goToVoiceStudio">
|
||||
<BaseButton @click="continueActiveVoiceSession">
|
||||
<SparklesIcon class="h-5 w-5 mr-2" />
|
||||
继续语音共创
|
||||
</BaseButton>
|
||||
@@ -261,7 +333,7 @@ watch([selectedWindow, selectedCapability], () => {
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800">语音共创运营摘要</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-gray-600">
|
||||
最近 {{ voiceAnalytics.window_days ?? 30 }} 天,你的语音共创已经累计
|
||||
{{ voiceAnalyticsWindowLabel }},你的语音共创已经累计
|
||||
{{ voiceAnalytics.total_sessions }} 个会话、{{ voiceAnalytics.total_turns }} 个 turn。
|
||||
</p>
|
||||
<p
|
||||
@@ -271,6 +343,49 @@ watch([selectedWindow, selectedCapability], () => {
|
||||
低置信度确认 {{ voiceAnalytics.low_confidence_turns }} 次,
|
||||
安全介入 {{ voiceAnalytics.safety_interventions }} 次。
|
||||
</p>
|
||||
<p
|
||||
v-if="voiceAnalytics.attention_sessions"
|
||||
class="mt-2 text-sm text-amber-700"
|
||||
>
|
||||
当前仍有 {{ voiceAnalytics.attention_sessions }} 个语音会话建议优先回到工作台处理:
|
||||
待确认 {{ voiceAnalytics.confirmation_attention_sessions }} 个,
|
||||
安全介入 {{ voiceAnalytics.safety_attention_sessions }} 个,
|
||||
失败待处理 {{ voiceAnalytics.failed_attention_sessions }} 个。
|
||||
</p>
|
||||
<div
|
||||
v-if="voiceAnalytics.attention_sessions"
|
||||
class="mt-3 flex flex-wrap gap-2"
|
||||
>
|
||||
<button
|
||||
v-if="voiceAnalytics.confirmation_attention_sessions"
|
||||
type="button"
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-1.5 text-sm text-amber-700 transition-colors hover:border-amber-400"
|
||||
@click="goToVoiceStudio({ reason: 'pending_confirmation', focus: 'confirmation' })"
|
||||
>
|
||||
查看待确认会话
|
||||
</button>
|
||||
<button
|
||||
v-if="voiceAnalytics.safety_attention_sessions"
|
||||
type="button"
|
||||
class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-sm text-rose-700 transition-colors hover:border-rose-400"
|
||||
@click="goToVoiceStudio({ reason: 'safety_intervention', focus: 'safety' })"
|
||||
>
|
||||
查看安全介入会话
|
||||
</button>
|
||||
<button
|
||||
v-if="voiceAnalytics.failed_attention_sessions"
|
||||
type="button"
|
||||
class="rounded-lg border border-slate-200 bg-slate-100 px-3 py-1.5 text-sm text-slate-700 transition-colors hover:border-slate-400"
|
||||
@click="goToVoiceStudio({ reason: 'failed_turn', focus: 'failed' })"
|
||||
>
|
||||
查看失败待处理会话
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button type="button" class="rounded-lg border px-3 py-1.5 text-sm transition-colors" :class="selectedVoiceWindow === '7' ? 'border-violet-700 bg-violet-700 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" @click="setVoiceWindow('7')">最近 7 天</button>
|
||||
<button type="button" class="rounded-lg border px-3 py-1.5 text-sm transition-colors" :class="selectedVoiceWindow === '30' ? 'border-violet-700 bg-violet-700 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" @click="setVoiceWindow('30')">最近 30 天</button>
|
||||
<button type="button" class="rounded-lg border px-3 py-1.5 text-sm transition-colors" :class="selectedVoiceWindow === 'all' ? 'border-violet-700 bg-violet-700 text-white' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" @click="setVoiceWindow('all')">全部</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:min-w-[520px]">
|
||||
<div class="rounded-lg border border-white/80 bg-white px-3 py-3">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import type {
|
||||
@@ -40,7 +40,12 @@ interface StoryUniverse {
|
||||
name: string
|
||||
}
|
||||
|
||||
type SessionFilter = 'active' | 'attention' | 'recent'
|
||||
type AttentionReasonFilter = 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn'
|
||||
type VoiceStudioFocusTarget = 'confirmation' | 'safety' | 'failed' | 'text'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const sessions = ref<VoiceSessionSummary[]>([])
|
||||
@@ -50,7 +55,9 @@ const profiles = ref<ChildProfile[]>([])
|
||||
const universes = ref<StoryUniverse[]>([])
|
||||
const selectedProfileId = ref('')
|
||||
const selectedUniverseId = ref('')
|
||||
const sessionFilter = ref<'active' | 'recent'>('active')
|
||||
const sessionFilter = ref<SessionFilter>('active')
|
||||
const attentionReasonFilter = ref<AttentionReasonFilter>('all')
|
||||
const analyticsWindow = ref<'7' | '30' | 'all'>('30')
|
||||
const textTurnInput = ref('')
|
||||
const uploadTranscriptHint = ref('')
|
||||
const loadingSessions = ref(false)
|
||||
@@ -72,6 +79,7 @@ let recordingChunks: Blob[] = []
|
||||
let recordingTimer: number | null = null
|
||||
let recordingStartedAt = 0
|
||||
let sessionPollTimer: number | null = null
|
||||
let autoAdvanceNoticeTimer: number | null = null
|
||||
|
||||
const recordedBlob = ref<Blob | null>(null)
|
||||
const recordedAudioUrl = ref<string | null>(null)
|
||||
@@ -82,9 +90,41 @@ const profileOptions = computed(() =>
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
const activeTurnList = computed(() => activeSession.value?.recent_turns ?? [])
|
||||
const hasPendingConfirmation = computed(() => activeSession.value?.latest_requires_confirmation ?? false)
|
||||
const latestPendingConfirmationTurn = computed(
|
||||
() => [...activeTurnList.value].reverse().find((turn) => turn.requires_confirmation) ?? null,
|
||||
)
|
||||
const hasFailedAttention = computed(
|
||||
() => activeSession.value?.attention_reasons.includes('failed_turn') ?? false,
|
||||
)
|
||||
const latestFailedTurn = computed(
|
||||
() => [...activeTurnList.value].reverse().find((turn) => turn.status === 'failed') ?? null,
|
||||
)
|
||||
const latestSafetyTurn = computed(
|
||||
() => [...activeTurnList.value].reverse().find((turn) => Boolean(turn.safety_message)) ?? null,
|
||||
)
|
||||
const requestedSessionId = computed(() =>
|
||||
typeof route.query.session === 'string' ? route.query.session : null,
|
||||
)
|
||||
const finalStorySummary = computed(() => {
|
||||
const value = activeSession.value?.story_state?.final_summary
|
||||
return typeof value === 'string' ? value : null
|
||||
@@ -99,6 +139,9 @@ const finalizeConversionRateLabel = computed(() => {
|
||||
if (!voiceAnalytics.value) return '0%'
|
||||
return `${Math.round(voiceAnalytics.value.finalize_conversion_rate * 100)}%`
|
||||
})
|
||||
const analyticsWindowLabel = computed(() =>
|
||||
formatAnalyticsWindowLabel(voiceAnalytics.value?.window_days ?? null),
|
||||
)
|
||||
const transcriptionModeDescription = computed(() => {
|
||||
switch (activeSession.value?.transcription_mode_hint) {
|
||||
case 'openai':
|
||||
@@ -115,6 +158,14 @@ const isSessionProcessing = computed(
|
||||
|| activeSession.value?.last_turn_status === 'received'
|
||||
|| activeSession.value?.last_turn_status === 'transcribing',
|
||||
)
|
||||
const pendingFocusTarget = ref<VoiceStudioFocusTarget | null>(null)
|
||||
const suppressRouteReload = ref(false)
|
||||
const suppressAutoAdvanceNotice = ref(false)
|
||||
const autoAdvanceNotice = ref<{
|
||||
fromTitle: string
|
||||
toTitle: string
|
||||
reasonLabel: string
|
||||
} | null>(null)
|
||||
|
||||
function formatSessionStatus(status: string) {
|
||||
switch (status) {
|
||||
@@ -187,6 +238,161 @@ function formatConfidence(value: number | null | undefined) {
|
||||
return `${Math.round(value * 100)}%`
|
||||
}
|
||||
|
||||
function formatAnalyticsWindowLabel(windowDays: number | null | undefined) {
|
||||
if (typeof windowDays === 'number') {
|
||||
return `最近 ${windowDays} 天`
|
||||
}
|
||||
return '全部历史'
|
||||
}
|
||||
|
||||
function sessionNeedsAttention(session: VoiceSessionSummary) {
|
||||
return session.attention_reasons.length > 0
|
||||
}
|
||||
|
||||
function formatAttentionReason(reason: string) {
|
||||
switch (reason) {
|
||||
case 'pending_confirmation':
|
||||
return {
|
||||
label: '待确认',
|
||||
className: 'bg-amber-100 text-amber-700',
|
||||
}
|
||||
case 'safety_intervention':
|
||||
return {
|
||||
label: '安全介入',
|
||||
className: 'bg-rose-100 text-rose-700',
|
||||
}
|
||||
case 'failed_turn':
|
||||
return {
|
||||
label: '失败待处理',
|
||||
className: 'bg-slate-100 text-slate-700',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
label: reason,
|
||||
className: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseSessionFilter(value: unknown): SessionFilter | null {
|
||||
if (value === 'active' || value === 'attention' || value === 'recent') {
|
||||
return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseAttentionReasonFilter(value: unknown): AttentionReasonFilter | null {
|
||||
if (
|
||||
value === 'all'
|
||||
|| value === 'pending_confirmation'
|
||||
|| value === 'safety_intervention'
|
||||
|| value === 'failed_turn'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseFocusTarget(value: unknown): VoiceStudioFocusTarget | null {
|
||||
if (value === 'confirmation' || value === 'safety' || value === 'failed' || value === 'text') {
|
||||
return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function applyRouteState() {
|
||||
suppressAutoAdvanceNotice.value = true
|
||||
sessionFilter.value = parseSessionFilter(route.query.filter) ?? 'active'
|
||||
attentionReasonFilter.value = parseAttentionReasonFilter(route.query.reason) ?? 'all'
|
||||
pendingFocusTarget.value = parseFocusTarget(route.query.focus)
|
||||
}
|
||||
|
||||
function buildVoiceStudioRouteQuery(options?: {
|
||||
sessionId?: string | null
|
||||
focus?: VoiceStudioFocusTarget | null
|
||||
}) {
|
||||
const query: Record<string, string> = {}
|
||||
if (sessionFilter.value !== 'active') {
|
||||
query.filter = sessionFilter.value
|
||||
}
|
||||
if (sessionFilter.value === 'attention' && attentionReasonFilter.value !== 'all') {
|
||||
query.reason = attentionReasonFilter.value
|
||||
}
|
||||
if (options?.sessionId) {
|
||||
query.session = options.sessionId
|
||||
}
|
||||
if (options?.focus) {
|
||||
query.focus = options.focus
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
function routeQueryMatches(nextQuery: Record<string, string>) {
|
||||
const currentEntries = Object.entries(route.query).filter(([, value]) => typeof value === 'string')
|
||||
if (currentEntries.length !== Object.keys(nextQuery).length) {
|
||||
return false
|
||||
}
|
||||
return currentEntries.every(([key, value]) => nextQuery[key] === value)
|
||||
}
|
||||
|
||||
async function syncVoiceStudioRouteState(options?: {
|
||||
sessionId?: string | null
|
||||
focus?: VoiceStudioFocusTarget | null
|
||||
}) {
|
||||
const nextQuery = buildVoiceStudioRouteQuery(options)
|
||||
if (routeQueryMatches(nextQuery)) {
|
||||
return
|
||||
}
|
||||
suppressRouteReload.value = true
|
||||
await router.replace({ path: '/voice-studio', query: nextQuery })
|
||||
}
|
||||
|
||||
function buildVoiceAnalyticsPath() {
|
||||
if (analyticsWindow.value === 'all') {
|
||||
return '/api/voice-sessions/analytics'
|
||||
}
|
||||
return `/api/voice-sessions/analytics?days=${analyticsWindow.value}`
|
||||
}
|
||||
|
||||
function buildVoiceSessionListPath() {
|
||||
const params = new URLSearchParams({
|
||||
limit: '12',
|
||||
active_first: 'true',
|
||||
})
|
||||
if (sessionFilter.value === 'active') {
|
||||
params.set('active_only', 'true')
|
||||
}
|
||||
if (sessionFilter.value === 'attention') {
|
||||
params.set('needs_attention', 'true')
|
||||
if (attentionReasonFilter.value !== 'all') {
|
||||
params.set('attention_reason', attentionReasonFilter.value)
|
||||
}
|
||||
}
|
||||
return `/api/voice-sessions?${params.toString()}`
|
||||
}
|
||||
|
||||
function formatAttentionReasonTitle(reason: 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn') {
|
||||
switch (reason) {
|
||||
case 'pending_confirmation':
|
||||
return '待确认'
|
||||
case 'safety_intervention':
|
||||
return '安全介入'
|
||||
case 'failed_turn':
|
||||
return '失败待处理'
|
||||
default:
|
||||
return '全部需关注'
|
||||
}
|
||||
}
|
||||
|
||||
function formatAttentionEmptyDescription(
|
||||
reason: 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn',
|
||||
) {
|
||||
if (reason === 'all') {
|
||||
return '当前没有待确认、安全介入或失败待处理的会话。'
|
||||
}
|
||||
return `当前筛选下没有“${formatAttentionReasonTitle(reason)}”相关会话。`
|
||||
}
|
||||
|
||||
function revokeRecordedAudioUrl() {
|
||||
if (recordedAudioUrl.value) {
|
||||
URL.revokeObjectURL(recordedAudioUrl.value)
|
||||
@@ -200,6 +406,30 @@ function clearRecordedAudio() {
|
||||
recordingDurationMs.value = 0
|
||||
}
|
||||
|
||||
function clearAutoAdvanceNotice() {
|
||||
if (autoAdvanceNoticeTimer) {
|
||||
window.clearTimeout(autoAdvanceNoticeTimer)
|
||||
autoAdvanceNoticeTimer = null
|
||||
}
|
||||
autoAdvanceNotice.value = null
|
||||
}
|
||||
|
||||
function showAutoAdvanceNotice(fromTitle: string, toTitle: string) {
|
||||
clearAutoAdvanceNotice()
|
||||
autoAdvanceNotice.value = {
|
||||
fromTitle,
|
||||
toTitle,
|
||||
reasonLabel:
|
||||
attentionReasonFilter.value === 'all'
|
||||
? '需关注'
|
||||
: formatAttentionReasonTitle(attentionReasonFilter.value),
|
||||
}
|
||||
autoAdvanceNoticeTimer = window.setTimeout(() => {
|
||||
autoAdvanceNotice.value = null
|
||||
autoAdvanceNoticeTimer = null
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
async function fetchProfiles() {
|
||||
if (!userStore.user) return
|
||||
const data = await api.get<{ profiles: ChildProfile[] }>('/api/profiles')
|
||||
@@ -226,21 +456,54 @@ async function loadSessions() {
|
||||
if (!userStore.user) return
|
||||
loadingSessions.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: '8',
|
||||
active_first: 'true',
|
||||
active_only: sessionFilter.value === 'active' ? 'true' : 'false',
|
||||
})
|
||||
sessions.value = await api.get<VoiceSessionSummary[]>(`/api/voice-sessions?${params.toString()}`)
|
||||
if (!activeSession.value && sessionFilter.value === 'recent') {
|
||||
const resumable = sessions.value.find((item) => item.can_continue)
|
||||
if (resumable) {
|
||||
await loadSessionDetail(resumable.id)
|
||||
const previousActiveSession = activeSession.value
|
||||
sessions.value = await api.get<VoiceSessionSummary[]>(buildVoiceSessionListPath())
|
||||
if (
|
||||
(requestedSessionId.value || pendingFocusTarget.value)
|
||||
&& requestedSessionId.value
|
||||
&& !sessions.value.some((item) => item.id === requestedSessionId.value)
|
||||
) {
|
||||
void syncVoiceStudioRouteState()
|
||||
}
|
||||
const preferredSession = (
|
||||
requestedSessionId.value
|
||||
? sessions.value.find((item) => item.id === requestedSessionId.value)
|
||||
: null
|
||||
) ?? sessions.value.find((item) => item.can_continue) ?? sessions.value[0]
|
||||
const currentSessionStillVisible = activeSession.value
|
||||
? sessions.value.some((item) => item.id === activeSession.value?.id)
|
||||
: false
|
||||
if (
|
||||
!activeSession.value
|
||||
|| (!currentSessionStillVisible && preferredSession)
|
||||
|| (
|
||||
requestedSessionId.value
|
||||
&& preferredSession?.id === requestedSessionId.value
|
||||
&& activeSession.value.id !== requestedSessionId.value
|
||||
)
|
||||
) {
|
||||
if (preferredSession) {
|
||||
if (
|
||||
sessionFilter.value === 'attention'
|
||||
&& previousActiveSession
|
||||
&& previousActiveSession.id !== preferredSession.id
|
||||
&& !currentSessionStillVisible
|
||||
&& !suppressAutoAdvanceNotice.value
|
||||
) {
|
||||
showAutoAdvanceNotice(
|
||||
previousActiveSession.working_title || '未命名语音会话',
|
||||
preferredSession.working_title || '未命名语音会话',
|
||||
)
|
||||
}
|
||||
await loadSessionDetail(preferredSession.id)
|
||||
} else if (sessionFilter.value !== 'recent') {
|
||||
activeSession.value = null
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '会话列表加载失败'
|
||||
} finally {
|
||||
suppressAutoAdvanceNotice.value = false
|
||||
loadingSessions.value = false
|
||||
}
|
||||
}
|
||||
@@ -248,7 +511,7 @@ async function loadSessions() {
|
||||
async function loadVoiceAnalytics() {
|
||||
if (!userStore.user) return
|
||||
try {
|
||||
voiceAnalytics.value = await api.get<VoiceSessionAnalytics>('/api/voice-sessions/analytics?days=30')
|
||||
voiceAnalytics.value = await api.get<VoiceSessionAnalytics>(buildVoiceAnalyticsPath())
|
||||
} catch {
|
||||
// Ignore analytics failures so the main editor stays usable.
|
||||
}
|
||||
@@ -271,6 +534,7 @@ async function loadSessionDetail(sessionId: string) {
|
||||
error.value = ''
|
||||
try {
|
||||
activeSession.value = await api.get<VoiceSessionDetail>(`/api/voice-sessions/${sessionId}`)
|
||||
await focusRequestedTarget(sessionId)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '会话详情加载失败'
|
||||
} finally {
|
||||
@@ -548,6 +812,85 @@ function viewFinalStory() {
|
||||
router.push(`/story/${activeSession.value.final_story_id}`)
|
||||
}
|
||||
|
||||
function setAnalyticsWindow(value: '7' | '30' | 'all') {
|
||||
analyticsWindow.value = value
|
||||
}
|
||||
|
||||
function setSessionFilter(value: SessionFilter) {
|
||||
suppressAutoAdvanceNotice.value = true
|
||||
sessionFilter.value = value
|
||||
if (value !== 'attention') {
|
||||
attentionReasonFilter.value = 'all'
|
||||
}
|
||||
void syncVoiceStudioRouteState()
|
||||
}
|
||||
|
||||
function jumpToTextTurnComposer(presetText = '') {
|
||||
textTurnInput.value = presetText
|
||||
clearRecordedAudio()
|
||||
void nextTick(() => {
|
||||
document.getElementById('voice-text-composer')?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function setAttentionReasonFilter(
|
||||
value: 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn',
|
||||
) {
|
||||
suppressAutoAdvanceNotice.value = true
|
||||
attentionReasonFilter.value = value
|
||||
void syncVoiceStudioRouteState()
|
||||
}
|
||||
|
||||
function focusAttentionReason(
|
||||
value: 'pending_confirmation' | 'safety_intervention' | 'failed_turn',
|
||||
) {
|
||||
setSessionFilter('attention')
|
||||
attentionReasonFilter.value = value
|
||||
void syncVoiceStudioRouteState()
|
||||
}
|
||||
|
||||
async function focusRequestedTarget(sessionId: string) {
|
||||
const target = pendingFocusTarget.value
|
||||
if (!target) return
|
||||
if (requestedSessionId.value && requestedSessionId.value !== sessionId) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
const targetId = (() => {
|
||||
switch (target) {
|
||||
case 'confirmation':
|
||||
return latestPendingConfirmationTurn.value
|
||||
? `voice-turn-${latestPendingConfirmationTurn.value.id}`
|
||||
: 'voice-attention-confirmation-card'
|
||||
case 'safety':
|
||||
return latestSafetyTurn.value
|
||||
? `voice-turn-${latestSafetyTurn.value.id}`
|
||||
: 'voice-attention-safety-card'
|
||||
case 'failed':
|
||||
return latestFailedTurn.value
|
||||
? `voice-turn-${latestFailedTurn.value.id}`
|
||||
: 'voice-attention-failed-card'
|
||||
case 'text':
|
||||
return 'voice-text-composer'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
if (targetId) {
|
||||
document.getElementById(targetId)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
pendingFocusTarget.value = null
|
||||
void syncVoiceStudioRouteState()
|
||||
}
|
||||
|
||||
watch(selectedProfileId, (newId) => {
|
||||
if (newId) {
|
||||
void fetchUniverses(newId)
|
||||
@@ -557,10 +900,35 @@ watch(selectedProfileId, (newId) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(analyticsWindow, () => {
|
||||
void loadVoiceAnalytics()
|
||||
})
|
||||
|
||||
watch(sessionFilter, () => {
|
||||
void loadSessions()
|
||||
})
|
||||
|
||||
watch(attentionReasonFilter, () => {
|
||||
if (sessionFilter.value === 'attention') {
|
||||
void loadSessions()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
applyRouteState()
|
||||
if (suppressRouteReload.value) {
|
||||
suppressRouteReload.value = false
|
||||
return
|
||||
}
|
||||
if (userStore.user) {
|
||||
void loadSessions()
|
||||
void loadVoiceAnalytics()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => isSessionProcessing.value,
|
||||
(processing) => {
|
||||
@@ -574,6 +942,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
applyRouteState()
|
||||
if (!userStore.user) {
|
||||
await userStore.fetchSession()
|
||||
}
|
||||
@@ -590,6 +959,7 @@ onBeforeUnmount(() => {
|
||||
stopRecording()
|
||||
}
|
||||
clearRecordedAudio()
|
||||
clearAutoAdvanceNotice()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -655,49 +1025,113 @@ onBeforeUnmount(() => {
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">最近会话</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">支持恢复最近还在等待下一轮的 session。</p>
|
||||
<p class="mt-1 text-sm text-gray-500">优先把需要家长确认或安全回看的 session 先拎出来处理。</p>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{{ sessions.length }} 个</span>
|
||||
<span class="text-xs text-gray-400">{{ filteredSessions.length }} 个</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="sessionFilter === 'active'
|
||||
? 'border-purple-600 bg-purple-600 text-white'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
|
||||
@click="sessionFilter = 'active'"
|
||||
@click="setSessionFilter('active')"
|
||||
>
|
||||
活跃会话
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="sessionFilter === 'attention'
|
||||
? 'border-amber-500 bg-amber-500 text-white'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
|
||||
@click="setSessionFilter('attention')"
|
||||
>
|
||||
需关注
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="sessionFilter === 'recent'
|
||||
? 'border-purple-600 bg-purple-600 text-white'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
|
||||
@click="sessionFilter = 'recent'"
|
||||
@click="setSessionFilter('recent')"
|
||||
>
|
||||
最近全部
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="sessionFilter === 'attention'"
|
||||
class="mt-3 flex flex-wrap gap-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="attentionReasonFilter === 'all'
|
||||
? 'border-amber-600 bg-amber-50 text-amber-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
|
||||
@click="setAttentionReasonFilter('all')"
|
||||
>
|
||||
全部需关注
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="attentionReasonFilter === 'pending_confirmation'
|
||||
? 'border-amber-600 bg-amber-50 text-amber-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
|
||||
@click="setAttentionReasonFilter('pending_confirmation')"
|
||||
>
|
||||
待确认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="attentionReasonFilter === 'safety_intervention'
|
||||
? 'border-rose-500 bg-rose-50 text-rose-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
|
||||
@click="setAttentionReasonFilter('safety_intervention')"
|
||||
>
|
||||
安全介入
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="attentionReasonFilter === 'failed_turn'
|
||||
? 'border-slate-500 bg-slate-100 text-slate-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
|
||||
@click="setAttentionReasonFilter('failed_turn')"
|
||||
>
|
||||
失败待处理
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingSessions" class="py-8">
|
||||
<LoadingSpinner text="加载会话中..." />
|
||||
</div>
|
||||
|
||||
<div v-else-if="sessions.length === 0" class="pt-6">
|
||||
<div v-else-if="filteredSessions.length === 0" class="pt-6">
|
||||
<EmptyState
|
||||
:icon="SparklesIcon"
|
||||
title="还没有语音共创会话"
|
||||
description="先创建一个会话,再通过文本或录音开始第一轮故事。"
|
||||
:title="sessionFilter === 'attention'
|
||||
? '当前没有待处理会话'
|
||||
: sessionFilter === 'active'
|
||||
? '当前没有活跃会话'
|
||||
: '还没有语音共创会话'"
|
||||
:description="sessionFilter === 'attention'
|
||||
? formatAttentionEmptyDescription(attentionReasonFilter)
|
||||
: sessionFilter === 'active'
|
||||
? '最近的会话都已经完成或放弃了,可以新建一个继续共创。'
|
||||
: '先创建一个会话,再通过文本或录音开始第一轮故事。'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<button
|
||||
v-for="session in sessions"
|
||||
v-for="session in filteredSessions"
|
||||
:key="session.id"
|
||||
type="button"
|
||||
class="w-full rounded-2xl border px-4 py-3 text-left transition-all"
|
||||
@@ -711,9 +1145,37 @@ onBeforeUnmount(() => {
|
||||
<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
|
||||
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>
|
||||
</div>
|
||||
<div class="text-right text-xs text-gray-400">
|
||||
{{ formatDate(session.updated_at) }}
|
||||
@@ -732,13 +1194,64 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard
|
||||
v-if="autoAdvanceNotice"
|
||||
class="border border-sky-100 bg-sky-50 text-sky-700"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="text-sm leading-6">
|
||||
已处理上一条{{ autoAdvanceNotice.reasonLabel }}会话“{{ autoAdvanceNotice.fromTitle }}”,
|
||||
已自动切换到下一条:“{{ autoAdvanceNotice.toTitle }}”。
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-1 text-sky-500 transition-colors hover:bg-sky-100 hover:text-sky-700"
|
||||
@click="clearAutoAdvanceNotice"
|
||||
>
|
||||
<XMarkIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard v-if="voiceAnalytics" class="border border-slate-100 bg-white/90">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">语音共创观测</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">最近 {{ voiceAnalytics.window_days ?? 30 }} 天的会话质量概览。</p>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ analyticsWindowLabel }} 的会话质量概览。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="analyticsWindow === '7'
|
||||
? 'border-slate-900 bg-slate-900 text-white'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
|
||||
@click="setAnalyticsWindow('7')"
|
||||
>
|
||||
最近 7 天
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="analyticsWindow === '30'
|
||||
? 'border-slate-900 bg-slate-900 text-white'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
|
||||
@click="setAnalyticsWindow('30')"
|
||||
>
|
||||
最近 30 天
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="analyticsWindow === 'all'
|
||||
? 'border-slate-900 bg-slate-900 text-white'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
|
||||
@click="setAnalyticsWindow('all')"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
|
||||
<div class="text-xs text-gray-500">Turn 成功率</div>
|
||||
@@ -759,8 +1272,46 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-500">
|
||||
ASR 失败 {{ voiceAnalytics.asr_failures }} 次,TTS 失败 {{ voiceAnalytics.tts_failures }} 次;
|
||||
当前共有 {{ voiceAnalytics.total_sessions }} 个会话,已完成 {{ voiceAnalytics.finalized_sessions }} 个。
|
||||
当前共有 {{ voiceAnalytics.total_sessions }} 个会话,其中 {{ voiceAnalytics.attention_sessions }} 个仍需处理,
|
||||
已完成 {{ voiceAnalytics.finalized_sessions }} 个。
|
||||
</p>
|
||||
<p
|
||||
v-if="voiceAnalytics.attention_sessions"
|
||||
class="mt-2 text-sm text-gray-500"
|
||||
>
|
||||
待确认 {{ voiceAnalytics.confirmation_attention_sessions }} 个,
|
||||
安全介入 {{ voiceAnalytics.safety_attention_sessions }} 个,
|
||||
失败待处理 {{ voiceAnalytics.failed_attention_sessions }} 个。
|
||||
</p>
|
||||
<div
|
||||
v-if="voiceAnalytics.attention_sessions"
|
||||
class="mt-3 flex flex-wrap gap-2"
|
||||
>
|
||||
<button
|
||||
v-if="voiceAnalytics.confirmation_attention_sessions"
|
||||
type="button"
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-1.5 text-sm text-amber-700 transition-colors hover:border-amber-400"
|
||||
@click="focusAttentionReason('pending_confirmation')"
|
||||
>
|
||||
查看待确认
|
||||
</button>
|
||||
<button
|
||||
v-if="voiceAnalytics.safety_attention_sessions"
|
||||
type="button"
|
||||
class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-sm text-rose-700 transition-colors hover:border-rose-400"
|
||||
@click="focusAttentionReason('safety_intervention')"
|
||||
>
|
||||
查看安全介入
|
||||
</button>
|
||||
<button
|
||||
v-if="voiceAnalytics.failed_attention_sessions"
|
||||
type="button"
|
||||
class="rounded-lg border border-slate-200 bg-slate-100 px-3 py-1.5 text-sm text-slate-700 transition-colors hover:border-slate-400"
|
||||
@click="focusAttentionReason('failed_turn')"
|
||||
>
|
||||
查看失败待处理
|
||||
</button>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<div v-if="loadingSessionDetail" class="py-16">
|
||||
@@ -828,7 +1379,8 @@ 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="activeSession.latest_requires_confirmation"
|
||||
v-if="activeSession.latest_requires_confirmation && latestPendingConfirmationTurn"
|
||||
id="voice-attention-confirmation-card"
|
||||
class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-amber-800"
|
||||
>
|
||||
<div class="text-sm font-semibold">建议先确认这一轮理解</div>
|
||||
@@ -838,10 +1390,37 @@ onBeforeUnmount(() => {
|
||||
<p v-if="activeSession.latest_understanding_summary" class="mt-2 text-xs text-amber-700">
|
||||
{{ activeSession.latest_understanding_summary }}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<BaseButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@click="resolveTurnConfirmation(latestPendingConfirmationTurn, 'accept')"
|
||||
:disabled="sendingTurn"
|
||||
>
|
||||
按这个理解继续
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="resolveTurnConfirmation(latestPendingConfirmationTurn, 'retry_recording')"
|
||||
:disabled="sendingTurn"
|
||||
>
|
||||
重说一遍
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="resolveTurnConfirmation(latestPendingConfirmationTurn, 'switch_to_text')"
|
||||
:disabled="sendingTurn"
|
||||
>
|
||||
改成文本输入
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeSession.latest_safety_message"
|
||||
id="voice-attention-safety-card"
|
||||
class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-700"
|
||||
>
|
||||
<div class="text-sm font-semibold">已触发儿童内容安全兜底</div>
|
||||
@@ -849,6 +1428,59 @@ onBeforeUnmount(() => {
|
||||
<p v-if="activeSession.latest_safety_flags.length" class="mt-2 text-xs text-rose-600">
|
||||
安全标记:{{ activeSession.latest_safety_flags.join(' / ') }}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<BaseButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@click="jumpToTextTurnComposer('')"
|
||||
:disabled="sendingTurn || !activeSession.can_continue"
|
||||
>
|
||||
用文本继续改写
|
||||
</BaseButton>
|
||||
<span
|
||||
v-if="latestSafetyTurn?.user_transcript"
|
||||
class="text-xs text-rose-600"
|
||||
>
|
||||
建议换一种更温和的表达再继续。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasFailedAttention"
|
||||
id="voice-attention-failed-card"
|
||||
class="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-slate-700"
|
||||
>
|
||||
<div class="text-sm font-semibold">有一轮失败待处理</div>
|
||||
<p class="mt-2 text-sm">
|
||||
<template v-if="latestFailedTurn">
|
||||
第 {{ latestFailedTurn.turn_index }} 轮没有成功完成,
|
||||
{{ latestFailedTurn.error_message || activeSession.last_error || '建议优先补处理后再继续。' }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ activeSession.last_error || '最近一轮失败待处理,建议优先补处理后再继续。' }}
|
||||
</template>
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<BaseButton
|
||||
v-if="latestFailedTurn"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@click="retryFailedTurn(latestFailedTurn.id)"
|
||||
:disabled="sendingTurn || !activeSession.can_continue || hasPendingConfirmation"
|
||||
>
|
||||
重试本轮
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="latestFailedTurn"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="jumpToTextTurnComposer(latestFailedTurn.user_transcript || '')"
|
||||
:disabled="sendingTurn || !activeSession.can_continue || hasPendingConfirmation"
|
||||
>
|
||||
改成文本重发
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -873,7 +1505,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-100 bg-white p-4">
|
||||
<div id="voice-text-composer" class="rounded-2xl border border-gray-100 bg-white p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold text-gray-900">文本共创回合</h3>
|
||||
<span class="text-xs text-gray-400">最稳的 fallback 路径</span>
|
||||
@@ -980,6 +1612,7 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
v-for="turn in activeTurnList"
|
||||
:key="turn.id"
|
||||
:id="`voice-turn-${turn.id}`"
|
||||
class="rounded-2xl border border-gray-100 bg-gray-50 p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-400">
|
||||
|
||||
Reference in New Issue
Block a user