feat: refine voice studio attention workflow

This commit is contained in:
2026-04-21 14:19:51 +08:00
parent 8b50674d04
commit 9f74a93274
7 changed files with 1025 additions and 48 deletions

View File

@@ -1,5 +1,7 @@
"""Voice co-creation session APIs.""" """Voice co-creation session APIs."""
from typing import Literal
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
Depends, Depends,
@@ -82,6 +84,10 @@ async def list_voice_sessions(
le=settings.voice_session_max_list_limit, le=settings.voice_session_max_list_limit,
), ),
active_only: bool = Query(default=False), active_only: bool = Query(default=False),
needs_attention: bool = Query(default=False),
attention_reason: (
Literal["pending_confirmation", "safety_intervention", "failed_turn"] | None
) = Query(default=None),
active_first: bool = Query(default=True), active_first: bool = Query(default=True),
user: User = Depends(require_user), user: User = Depends(require_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -92,6 +98,8 @@ async def list_voice_sessions(
db, db,
limit=limit, limit=limit,
active_only=active_only, active_only=active_only,
needs_attention=needs_attention,
attention_reason=attention_reason,
active_first=active_first, active_first=active_first,
) )

View File

@@ -121,6 +121,7 @@ class VoiceSessionSummaryResponse(BaseModel):
latest_safety_message: str | None = None latest_safety_message: str | None = None
latest_assistant_audio_ready: bool = False latest_assistant_audio_ready: bool = False
last_turn_status: str | None = None last_turn_status: str | None = None
attention_reasons: list[str] = Field(default_factory=list)
transcription_mode_hint: str | None = None transcription_mode_hint: str | None = None
can_continue: bool = False can_continue: bool = False
can_finalize: bool = False can_finalize: bool = False
@@ -149,6 +150,10 @@ class VoiceSessionAnalyticsResponse(BaseModel):
window_days: int | None = None window_days: int | None = None
total_sessions: int = 0 total_sessions: int = 0
attention_sessions: int = 0
confirmation_attention_sessions: int = 0
safety_attention_sessions: int = 0
failed_attention_sessions: int = 0
active_sessions: int = 0 active_sessions: int = 0
finalized_sessions: int = 0 finalized_sessions: int = 0
abandoned_sessions: int = 0 abandoned_sessions: int = 0

View File

@@ -388,6 +388,12 @@ def _session_to_summary(
story_patch=latest_turn.story_patch or {}, story_patch=latest_turn.story_patch or {},
) )
latest_safety_state = _resolve_turn_safety_state(latest_turn.story_patch or {}) latest_safety_state = _resolve_turn_safety_state(latest_turn.story_patch or {})
attention_reasons = _build_session_attention_reasons(
latest_requires_confirmation=latest_confirmation_state["requires_confirmation"],
latest_safety_flags=latest_safety_state["safety_flags"],
last_turn_status=latest_turn.status if latest_turn else None,
last_error=session.last_error,
)
return VoiceSessionSummaryResponse( return VoiceSessionSummaryResponse(
id=session.id, id=session.id,
@@ -413,12 +419,55 @@ def _session_to_summary(
session_audio_exists(latest_turn.assistant_audio_path) if latest_turn else False session_audio_exists(latest_turn.assistant_audio_path) if latest_turn else False
), ),
last_turn_status=latest_turn.status if latest_turn else None, last_turn_status=latest_turn.status if latest_turn else None,
attention_reasons=attention_reasons,
transcription_mode_hint=settings.voice_transcription_mode, transcription_mode_hint=settings.voice_transcription_mode,
can_continue=_session_can_continue(session), can_continue=_session_can_continue(session),
can_finalize=_can_finalize_with_latest_turn(session, latest_turn), can_finalize=_can_finalize_with_latest_turn(session, latest_turn),
last_error=session.last_error, last_error=session.last_error,
created_at=session.created_at, created_at=session.created_at,
updated_at=session.updated_at, updated_at=session.updated_at,
)
def _build_session_attention_reasons(
*,
latest_requires_confirmation: bool,
latest_safety_flags: list[str] | None,
last_turn_status: str | None,
last_error: str | None,
) -> list[str]:
reasons: list[str] = []
if latest_requires_confirmation:
reasons.append("pending_confirmation")
if latest_safety_flags:
reasons.append("safety_intervention")
if last_turn_status == "failed" or last_error:
reasons.append("failed_turn")
return reasons
def _session_summary_needs_attention(summary: VoiceSessionSummaryResponse) -> bool:
return bool(summary.attention_reasons)
def _session_summary_matches_attention_reason(
summary: VoiceSessionSummaryResponse,
attention_reason: str | None,
) -> bool:
if attention_reason is None:
return True
return attention_reason in summary.attention_reasons
async def _build_session_summary(
db: AsyncSession,
session: VoiceSession,
) -> VoiceSessionSummaryResponse:
latest_turn = await _get_latest_turn(db, session_id=session.id)
return _session_to_summary(
session,
latest_turn=latest_turn,
total_turns=session.current_turn_index,
) )
@@ -1082,6 +1131,8 @@ async def list_voice_sessions_service(
*, *,
limit: int | None = None, limit: int | None = None,
active_only: bool = False, active_only: bool = False,
needs_attention: bool = False,
attention_reason: str | None = None,
active_first: bool = False, active_first: bool = False,
) -> list[VoiceSessionSummaryResponse]: ) -> list[VoiceSessionSummaryResponse]:
resolved_limit = limit or settings.voice_session_default_list_limit resolved_limit = limit or settings.voice_session_default_list_limit
@@ -1102,19 +1153,20 @@ async def list_voice_sessions_service(
) )
else: else:
query = query.order_by(desc(VoiceSession.updated_at), desc(VoiceSession.created_at)) query = query.order_by(desc(VoiceSession.updated_at), desc(VoiceSession.created_at))
query = query.limit(resolved_limit) if not needs_attention and attention_reason is None:
query = query.limit(resolved_limit)
sessions = (await db.execute(query)).scalars().all() sessions = (await db.execute(query)).scalars().all()
summaries: list[VoiceSessionSummaryResponse] = [] summaries: list[VoiceSessionSummaryResponse] = []
for session in sessions: for session in sessions:
latest_turn = await _get_latest_turn(db, session_id=session.id) summary = await _build_session_summary(db, session)
summaries.append( if needs_attention and not _session_summary_needs_attention(summary):
_session_to_summary( continue
session, if not _session_summary_matches_attention_reason(summary, attention_reason):
latest_turn=latest_turn, continue
total_turns=session.current_turn_index, summaries.append(summary)
) if (needs_attention or attention_reason is not None) and len(summaries) >= resolved_limit:
) break
return summaries return summaries
@@ -1134,12 +1186,7 @@ async def get_latest_active_voice_session_service(
session = (await db.execute(query)).scalar_one_or_none() session = (await db.execute(query)).scalar_one_or_none()
if session is None: if session is None:
return None return None
latest_turn = await _get_latest_turn(db, session_id=session.id) return await _build_session_summary(db, session)
return _session_to_summary(
session,
latest_turn=latest_turn,
total_turns=session.current_turn_index,
)
async def get_voice_session_analytics_service( async def get_voice_session_analytics_service(
@@ -1172,8 +1219,25 @@ async def get_voice_session_analytics_service(
sessions = (await db.execute(session_query)).scalars().all() sessions = (await db.execute(session_query)).scalars().all()
turns = (await db.execute(turn_query)).scalars().all() turns = (await db.execute(turn_query)).scalars().all()
events = (await db.execute(event_query)).scalars().all() events = (await db.execute(event_query)).scalars().all()
session_summaries = [await _build_session_summary(db, session) for session in sessions]
total_sessions = len(sessions) total_sessions = len(sessions)
attention_sessions = sum(
1 for summary in session_summaries if _session_summary_needs_attention(summary)
)
confirmation_attention_sessions = sum(
1
for summary in session_summaries
if "pending_confirmation" in summary.attention_reasons
)
safety_attention_sessions = sum(
1
for summary in session_summaries
if "safety_intervention" in summary.attention_reasons
)
failed_attention_sessions = sum(
1 for summary in session_summaries if "failed_turn" in summary.attention_reasons
)
active_sessions = sum( active_sessions = sum(
1 for session in sessions if session.status in CONTINUABLE_SESSION_STATUSES 1 for session in sessions if session.status in CONTINUABLE_SESSION_STATUSES
) )
@@ -1205,6 +1269,10 @@ async def get_voice_session_analytics_service(
return VoiceSessionAnalyticsResponse( return VoiceSessionAnalyticsResponse(
window_days=days, window_days=days,
total_sessions=total_sessions, total_sessions=total_sessions,
attention_sessions=attention_sessions,
confirmation_attention_sessions=confirmation_attention_sessions,
safety_attention_sessions=safety_attention_sessions,
failed_attention_sessions=failed_attention_sessions,
active_sessions=active_sessions, active_sessions=active_sessions,
finalized_sessions=finalized_sessions, finalized_sessions=finalized_sessions,
abandoned_sessions=abandoned_sessions, abandoned_sessions=abandoned_sessions,

View File

@@ -681,6 +681,149 @@ async def test_voice_session_analytics_summarize_failures_and_confirmations(
app.dependency_overrides.clear() app.dependency_overrides.clear()
async def test_voice_session_attention_filter_and_analytics_count(
db_session,
auth_token,
):
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
with (
patch(
"app.services.voice_session_service.generate_story_content",
new_callable=AsyncMock,
) as mock_generate,
patch(
"app.services.voice_session_service.text_to_speech",
new_callable=AsyncMock,
) as mock_tts,
patch(
"app.services.voice_session_service.transcribe_voice_audio",
new_callable=AsyncMock,
) as mock_transcribe,
):
mock_generate.side_effect = [
StoryOutput(
mode="generated",
title="正常故事",
story_text="第一段温暖故事。",
cover_prompt_suggestion="normal cover",
),
RuntimeError("provider down"),
]
mock_tts.side_effect = [
b"normal-audio",
b"confirmation-audio",
b"safety-audio",
]
mock_transcribe.return_value = VoiceTranscriptionResult(
transcript_text="我想听一个会发光的小恐龙故事",
confidence=0.41,
provider="openai",
)
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
client.cookies.set("access_token", auth_token)
response = await client.post("/api/voice-sessions", json={})
normal_session_id = response.json()["id"]
response = await client.post(
f"/api/voice-sessions/{normal_session_id}/turns/fallback",
json={"transcript_text": "先讲一个温暖的普通故事"},
)
assert response.status_code == 202
response = await client.post("/api/voice-sessions", json={})
failed_session_id = response.json()["id"]
response = await client.post(
f"/api/voice-sessions/{failed_session_id}/turns/fallback",
json={"transcript_text": "这轮会触发 provider 异常"},
)
assert response.status_code == 202
response = await client.post("/api/voice-sessions", json={})
confirmation_session_id = response.json()["id"]
response = await client.post(
f"/api/voice-sessions/{confirmation_session_id}/turns",
files={
"audio_file": ("turn.webm", b"fake-webm-audio", "audio/webm"),
},
)
assert response.status_code == 202
response = await client.post("/api/voice-sessions", json={})
safety_session_id = response.json()["id"]
response = await client.post(
f"/api/voice-sessions/{safety_session_id}/turns/fallback",
json={"transcript_text": "我想听一个拿着炸弹互相打的故事"},
)
assert response.status_code == 202
response = await client.get(
"/api/voice-sessions?needs_attention=true&limit=8"
)
assert response.status_code == 200
attention_sessions = response.json()
attention_session_ids = {item["id"] for item in attention_sessions}
assert attention_session_ids == {
failed_session_id,
confirmation_session_id,
safety_session_id,
}
assert normal_session_id not in attention_session_ids
attention_reason_sets = {
item["id"]: set(item["attention_reasons"]) for item in attention_sessions
}
assert attention_reason_sets[confirmation_session_id] == {
"pending_confirmation"
}
assert attention_reason_sets[safety_session_id] == {
"safety_intervention"
}
assert attention_reason_sets[failed_session_id] == {"failed_turn"}
response = await client.get(
"/api/voice-sessions?needs_attention=true&attention_reason=pending_confirmation"
)
assert response.status_code == 200
confirmation_sessions = response.json()
assert [item["id"] for item in confirmation_sessions] == [
confirmation_session_id
]
response = await client.get(
"/api/voice-sessions?needs_attention=true&attention_reason=safety_intervention"
)
assert response.status_code == 200
safety_sessions = response.json()
assert [item["id"] for item in safety_sessions] == [safety_session_id]
response = await client.get(
"/api/voice-sessions?needs_attention=true&attention_reason=failed_turn"
)
assert response.status_code == 200
failed_sessions = response.json()
assert [item["id"] for item in failed_sessions] == [failed_session_id]
response = await client.get("/api/voice-sessions/analytics?days=30")
assert response.status_code == 200
analytics = response.json()
assert analytics["total_sessions"] == 4
assert analytics["attention_sessions"] == 3
assert analytics["confirmation_attention_sessions"] == 1
assert analytics["safety_attention_sessions"] == 1
assert analytics["failed_attention_sessions"] == 1
assert analytics["failed_turns"] >= 1
assert analytics["low_confidence_turns"] >= 1
assert analytics["safety_interventions"] >= 1
finally:
app.dependency_overrides.clear()
async def test_voice_session_list_orders_recent_sessions_first( async def test_voice_session_list_orders_recent_sessions_first(
db_session, db_session,
auth_token, auth_token,

View File

@@ -59,6 +59,7 @@ export interface VoiceSessionSummary {
latest_safety_message: string | null latest_safety_message: string | null
latest_assistant_audio_ready: boolean latest_assistant_audio_ready: boolean
last_turn_status: string | null last_turn_status: string | null
attention_reasons: string[]
transcription_mode_hint: string | null transcription_mode_hint: string | null
can_continue: boolean can_continue: boolean
can_finalize: boolean can_finalize: boolean
@@ -81,6 +82,10 @@ export interface VoiceTurnAcceptedResponse {
export interface VoiceSessionAnalytics { export interface VoiceSessionAnalytics {
window_days: number | null window_days: number | null
total_sessions: number total_sessions: number
attention_sessions: number
confirmation_attention_sessions: number
safety_attention_sessions: number
failed_attention_sessions: number
active_sessions: number active_sessions: number
finalized_sessions: number finalized_sessions: number
abandoned_sessions: number abandoned_sessions: number

View File

@@ -37,6 +37,9 @@ interface StoryItem {
last_error: string | null last_error: string | null
} }
type VoiceAttentionReason = 'pending_confirmation' | 'safety_intervention' | 'failed_turn'
type VoiceStudioFocusTarget = 'confirmation' | 'safety' | 'failed' | 'text'
const router = useRouter() const router = useRouter()
const stories = ref<StoryItem[]>([]) const stories = ref<StoryItem[]>([])
const providerAnalytics = ref<GenerationProviderAnalytics | null>(null) const providerAnalytics = ref<GenerationProviderAnalytics | null>(null)
@@ -47,6 +50,7 @@ const loading = ref(true)
const error = ref('') const error = ref('')
const showCreateModal = ref(false) const showCreateModal = ref(false)
const selectedWindow = ref<'7' | '30' | 'all'>('30') const selectedWindow = ref<'7' | '30' | 'all'>('30')
const selectedVoiceWindow = ref<'7' | '30' | 'all'>('30')
const selectedCapability = ref<'all' | 'text' | 'image' | 'tts' | 'storybook'>('all') const selectedCapability = ref<'all' | 'text' | 'image' | 'tts' | 'storybook'>('all')
const readableCount = computed(() => const readableCount = computed(() =>
@@ -72,6 +76,9 @@ const voiceFinalizeRate = computed(() => {
if (!voiceAnalytics.value) return null if (!voiceAnalytics.value) return null
return Math.round(voiceAnalytics.value.finalize_conversion_rate * 100) return Math.round(voiceAnalytics.value.finalize_conversion_rate * 100)
}) })
const voiceAnalyticsWindowLabel = computed(() =>
formatWindowLabel(voiceAnalytics.value?.window_days ?? null),
)
function buildProviderAnalyticsPath() { function buildProviderAnalyticsPath() {
const params = new URLSearchParams() const params = new URLSearchParams()
@@ -85,6 +92,13 @@ function buildProviderAnalyticsPath() {
return `/api/generations/provider-analytics${query ? `?${query}` : ''}` 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() { async function fetchStories() {
try { try {
const [storyList, analytics, ops, activeSession, voiceOverview] = await Promise.all([ const [storyList, analytics, ops, activeSession, voiceOverview] = await Promise.all([
@@ -92,7 +106,7 @@ async function fetchStories() {
api.get<GenerationProviderAnalytics>(buildProviderAnalyticsPath()), api.get<GenerationProviderAnalytics>(buildProviderAnalyticsPath()),
api.get<GenerationOpsSummary>('/api/generations/ops-summary'), api.get<GenerationOpsSummary>('/api/generations/ops-summary'),
api.get<VoiceSessionSummary | null>('/api/voice-sessions/active').catch(() => null), 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 stories.value = storyList
providerAnalytics.value = analytics 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() { function goToCreate() {
showCreateModal.value = true showCreateModal.value = true
} }
function goToVoiceStudio() { function goToVoiceStudio(options?: {
router.push('/voice-studio') 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) { function getStoryLink(story: StoryItem) {
@@ -160,6 +228,10 @@ function setWindow(value: '7' | '30' | 'all') {
selectedWindow.value = value selectedWindow.value = value
} }
function setVoiceWindow(value: '7' | '30' | 'all') {
selectedVoiceWindow.value = value
}
function setCapability(value: 'all' | 'text' | 'image' | 'tts' | 'storybook') { function setCapability(value: 'all' | 'text' | 'image' | 'tts' | 'storybook') {
selectedCapability.value = value selectedCapability.value = value
} }
@@ -173,7 +245,7 @@ onMounted(() => {
} }
}) })
watch([selectedWindow, selectedCapability], () => { watch([selectedWindow, selectedCapability, selectedVoiceWindow], () => {
void fetchStories() void fetchStories()
}) })
</script> </script>
@@ -245,7 +317,7 @@ watch([selectedWindow, selectedCapability], () => {
最近一轮触发了儿童内容安全兜底建议回到工作台查看详细记录 最近一轮触发了儿童内容安全兜底建议回到工作台查看详细记录
</p> </p>
</div> </div>
<BaseButton @click="goToVoiceStudio"> <BaseButton @click="continueActiveVoiceSession">
<SparklesIcon class="h-5 w-5 mr-2" /> <SparklesIcon class="h-5 w-5 mr-2" />
继续语音共创 继续语音共创
</BaseButton> </BaseButton>
@@ -261,7 +333,7 @@ watch([selectedWindow, selectedCapability], () => {
<div> <div>
<h2 class="text-xl font-bold text-gray-800">语音共创运营摘要</h2> <h2 class="text-xl font-bold text-gray-800">语音共创运营摘要</h2>
<p class="mt-2 text-sm leading-6 text-gray-600"> <p class="mt-2 text-sm leading-6 text-gray-600">
最近 {{ voiceAnalytics.window_days ?? 30 }} 你的语音共创已经累计 {{ voiceAnalyticsWindowLabel }}你的语音共创已经累计
{{ voiceAnalytics.total_sessions }} 个会话{{ voiceAnalytics.total_turns }} turn {{ voiceAnalytics.total_sessions }} 个会话{{ voiceAnalytics.total_turns }} turn
</p> </p>
<p <p
@@ -271,6 +343,49 @@ watch([selectedWindow, selectedCapability], () => {
低置信度确认 {{ voiceAnalytics.low_confidence_turns }} 低置信度确认 {{ voiceAnalytics.low_confidence_turns }}
安全介入 {{ voiceAnalytics.safety_interventions }} 安全介入 {{ voiceAnalytics.safety_interventions }}
</p> </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>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:min-w-[520px]"> <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"> <div class="rounded-lg border border-white/80 bg-white px-3 py-3">

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client' import { api } from '../api/client'
import { useUserStore } from '../stores/user' import { useUserStore } from '../stores/user'
import type { import type {
@@ -40,7 +40,12 @@ interface StoryUniverse {
name: string 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 router = useRouter()
const route = useRoute()
const userStore = useUserStore() const userStore = useUserStore()
const sessions = ref<VoiceSessionSummary[]>([]) const sessions = ref<VoiceSessionSummary[]>([])
@@ -50,7 +55,9 @@ const profiles = ref<ChildProfile[]>([])
const universes = ref<StoryUniverse[]>([]) const universes = ref<StoryUniverse[]>([])
const selectedProfileId = ref('') const selectedProfileId = ref('')
const selectedUniverseId = 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 textTurnInput = ref('')
const uploadTranscriptHint = ref('') const uploadTranscriptHint = ref('')
const loadingSessions = ref(false) const loadingSessions = ref(false)
@@ -72,6 +79,7 @@ let recordingChunks: Blob[] = []
let recordingTimer: number | null = null let recordingTimer: number | null = null
let recordingStartedAt = 0 let recordingStartedAt = 0
let sessionPollTimer: number | null = null let sessionPollTimer: number | null = null
let autoAdvanceNoticeTimer: number | null = null
const recordedBlob = ref<Blob | null>(null) const recordedBlob = ref<Blob | null>(null)
const recordedAudioUrl = ref<string | null>(null) const recordedAudioUrl = ref<string | null>(null)
@@ -82,9 +90,41 @@ const profileOptions = computed(() =>
const universeOptions = computed(() => const universeOptions = computed(() =>
universes.value.map((universe) => ({ value: universe.id, label: universe.name })), 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 activeTurnList = computed(() => activeSession.value?.recent_turns ?? [])
const hasPendingConfirmation = computed(() => activeSession.value?.latest_requires_confirmation ?? false) 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 finalStorySummary = computed(() => {
const value = activeSession.value?.story_state?.final_summary const value = activeSession.value?.story_state?.final_summary
return typeof value === 'string' ? value : null return typeof value === 'string' ? value : null
@@ -99,6 +139,9 @@ const finalizeConversionRateLabel = computed(() => {
if (!voiceAnalytics.value) return '0%' if (!voiceAnalytics.value) return '0%'
return `${Math.round(voiceAnalytics.value.finalize_conversion_rate * 100)}%` return `${Math.round(voiceAnalytics.value.finalize_conversion_rate * 100)}%`
}) })
const analyticsWindowLabel = computed(() =>
formatAnalyticsWindowLabel(voiceAnalytics.value?.window_days ?? null),
)
const transcriptionModeDescription = computed(() => { const transcriptionModeDescription = computed(() => {
switch (activeSession.value?.transcription_mode_hint) { switch (activeSession.value?.transcription_mode_hint) {
case 'openai': case 'openai':
@@ -115,6 +158,14 @@ const isSessionProcessing = computed(
|| activeSession.value?.last_turn_status === 'received' || activeSession.value?.last_turn_status === 'received'
|| activeSession.value?.last_turn_status === 'transcribing', || 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) { function formatSessionStatus(status: string) {
switch (status) { switch (status) {
@@ -187,6 +238,161 @@ function formatConfidence(value: number | null | undefined) {
return `${Math.round(value * 100)}%` 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() { function revokeRecordedAudioUrl() {
if (recordedAudioUrl.value) { if (recordedAudioUrl.value) {
URL.revokeObjectURL(recordedAudioUrl.value) URL.revokeObjectURL(recordedAudioUrl.value)
@@ -200,6 +406,30 @@ function clearRecordedAudio() {
recordingDurationMs.value = 0 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() { async function fetchProfiles() {
if (!userStore.user) return if (!userStore.user) return
const data = await api.get<{ profiles: ChildProfile[] }>('/api/profiles') const data = await api.get<{ profiles: ChildProfile[] }>('/api/profiles')
@@ -226,21 +456,54 @@ async function loadSessions() {
if (!userStore.user) return if (!userStore.user) return
loadingSessions.value = true loadingSessions.value = true
try { try {
const params = new URLSearchParams({ const previousActiveSession = activeSession.value
limit: '8', sessions.value = await api.get<VoiceSessionSummary[]>(buildVoiceSessionListPath())
active_first: 'true', if (
active_only: sessionFilter.value === 'active' ? 'true' : 'false', (requestedSessionId.value || pendingFocusTarget.value)
}) && requestedSessionId.value
sessions.value = await api.get<VoiceSessionSummary[]>(`/api/voice-sessions?${params.toString()}`) && !sessions.value.some((item) => item.id === requestedSessionId.value)
if (!activeSession.value && sessionFilter.value === 'recent') { ) {
const resumable = sessions.value.find((item) => item.can_continue) void syncVoiceStudioRouteState()
if (resumable) { }
await loadSessionDetail(resumable.id) 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) { } catch (err) {
error.value = err instanceof Error ? err.message : '会话列表加载失败' error.value = err instanceof Error ? err.message : '会话列表加载失败'
} finally { } finally {
suppressAutoAdvanceNotice.value = false
loadingSessions.value = false loadingSessions.value = false
} }
} }
@@ -248,7 +511,7 @@ async function loadSessions() {
async function loadVoiceAnalytics() { async function loadVoiceAnalytics() {
if (!userStore.user) return if (!userStore.user) return
try { try {
voiceAnalytics.value = await api.get<VoiceSessionAnalytics>('/api/voice-sessions/analytics?days=30') voiceAnalytics.value = await api.get<VoiceSessionAnalytics>(buildVoiceAnalyticsPath())
} catch { } catch {
// Ignore analytics failures so the main editor stays usable. // Ignore analytics failures so the main editor stays usable.
} }
@@ -271,6 +534,7 @@ async function loadSessionDetail(sessionId: string) {
error.value = '' error.value = ''
try { try {
activeSession.value = await api.get<VoiceSessionDetail>(`/api/voice-sessions/${sessionId}`) activeSession.value = await api.get<VoiceSessionDetail>(`/api/voice-sessions/${sessionId}`)
await focusRequestedTarget(sessionId)
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : '会话详情加载失败' error.value = err instanceof Error ? err.message : '会话详情加载失败'
} finally { } finally {
@@ -548,6 +812,85 @@ function viewFinalStory() {
router.push(`/story/${activeSession.value.final_story_id}`) 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) => { watch(selectedProfileId, (newId) => {
if (newId) { if (newId) {
void fetchUniverses(newId) void fetchUniverses(newId)
@@ -557,10 +900,35 @@ watch(selectedProfileId, (newId) => {
} }
}) })
watch(analyticsWindow, () => {
void loadVoiceAnalytics()
})
watch(sessionFilter, () => { watch(sessionFilter, () => {
void loadSessions() 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( watch(
() => isSessionProcessing.value, () => isSessionProcessing.value,
(processing) => { (processing) => {
@@ -574,6 +942,7 @@ watch(
) )
onMounted(async () => { onMounted(async () => {
applyRouteState()
if (!userStore.user) { if (!userStore.user) {
await userStore.fetchSession() await userStore.fetchSession()
} }
@@ -590,6 +959,7 @@ onBeforeUnmount(() => {
stopRecording() stopRecording()
} }
clearRecordedAudio() clearRecordedAudio()
clearAutoAdvanceNotice()
}) })
</script> </script>
@@ -655,49 +1025,113 @@ onBeforeUnmount(() => {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-lg font-semibold text-gray-900">最近会话</h2> <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> </div>
<span class="text-xs text-gray-400">{{ sessions.length }} </span> <span class="text-xs text-gray-400">{{ filteredSessions.length }} </span>
</div> </div>
<div class="mt-4 flex gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<button <button
type="button" type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors" class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="sessionFilter === 'active' :class="sessionFilter === 'active'
? 'border-purple-600 bg-purple-600 text-white' ? 'border-purple-600 bg-purple-600 text-white'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="sessionFilter = 'active'" @click="setSessionFilter('active')"
> >
活跃会话 活跃会话
</button> </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 <button
type="button" type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors" class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="sessionFilter === 'recent' :class="sessionFilter === 'recent'
? 'border-purple-600 bg-purple-600 text-white' ? 'border-purple-600 bg-purple-600 text-white'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'" : 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="sessionFilter = 'recent'" @click="setSessionFilter('recent')"
> >
最近全部 最近全部
</button> </button>
</div> </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"> <div v-if="loadingSessions" class="py-8">
<LoadingSpinner text="加载会话中..." /> <LoadingSpinner text="加载会话中..." />
</div> </div>
<div v-else-if="sessions.length === 0" class="pt-6"> <div v-else-if="filteredSessions.length === 0" class="pt-6">
<EmptyState <EmptyState
:icon="SparklesIcon" :icon="SparklesIcon"
title="还没有语音共创会话" :title="sessionFilter === 'attention'
description="先创建一个会话,再通过文本或录音开始第一轮故事。" ? '当前没有待处理会话'
: sessionFilter === 'active'
? '当前没有活跃会话'
: '还没有语音共创会话'"
:description="sessionFilter === 'attention'
? formatAttentionEmptyDescription(attentionReasonFilter)
: sessionFilter === 'active'
? '最近的会话都已经完成或放弃了,可以新建一个继续共创。'
: '先创建一个会话,再通过文本或录音开始第一轮故事。'"
/> />
</div> </div>
<div v-else class="mt-4 space-y-3"> <div v-else class="mt-4 space-y-3">
<button <button
v-for="session in sessions" v-for="session in filteredSessions"
:key="session.id" :key="session.id"
type="button" type="button"
class="w-full rounded-2xl border px-4 py-3 text-left transition-all" 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"> <div class="truncate font-medium text-gray-900">
{{ session.working_title || '未命名语音会话' }} {{ session.working_title || '未命名语音会话' }}
</div> </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"> <div class="mt-1 text-xs text-gray-500">
{{ formatSessionStatus(session.status) }} · {{ session.total_turns }} {{ formatSessionStatus(session.status) }} · {{ session.total_turns }}
</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"
>
{{ session.latest_understanding_summary || session.latest_safety_message || session.latest_user_transcript }}
</div>
</div> </div>
<div class="text-right text-xs text-gray-400"> <div class="text-right text-xs text-gray-400">
{{ formatDate(session.updated_at) }} {{ formatDate(session.updated_at) }}
@@ -732,13 +1194,64 @@ onBeforeUnmount(() => {
</div> </div>
</BaseCard> </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"> <BaseCard v-if="voiceAnalytics" class="border border-slate-100 bg-white/90">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-lg font-semibold text-gray-900">语音共创观测</h2> <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> </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="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="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">Turn 成功率</div> <div class="text-xs text-gray-500">Turn 成功率</div>
@@ -759,8 +1272,46 @@ onBeforeUnmount(() => {
</div> </div>
<p class="mt-4 text-sm text-gray-500"> <p class="mt-4 text-sm text-gray-500">
ASR 失败 {{ voiceAnalytics.asr_failures }} TTS 失败 {{ voiceAnalytics.tts_failures }} 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>
<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> </BaseCard>
<div v-if="loadingSessionDetail" class="py-16"> <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="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 class="space-y-6">
<div <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" class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-amber-800"
> >
<div class="text-sm font-semibold">建议先确认这一轮理解</div> <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"> <p v-if="activeSession.latest_understanding_summary" class="mt-2 text-xs text-amber-700">
{{ activeSession.latest_understanding_summary }} {{ activeSession.latest_understanding_summary }}
</p> </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>
<div <div
v-if="activeSession.latest_safety_message" 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" class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-700"
> >
<div class="text-sm font-semibold">已触发儿童内容安全兜底</div> <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"> <p v-if="activeSession.latest_safety_flags.length" class="mt-2 text-xs text-rose-600">
安全标记{{ activeSession.latest_safety_flags.join(' / ') }} 安全标记{{ activeSession.latest_safety_flags.join(' / ') }}
</p> </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>
<div <div
@@ -873,7 +1505,7 @@ onBeforeUnmount(() => {
</div> </div>
</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"> <div class="flex items-center justify-between">
<h3 class="font-semibold text-gray-900">文本共创回合</h3> <h3 class="font-semibold text-gray-900">文本共创回合</h3>
<span class="text-xs text-gray-400">最稳的 fallback 路径</span> <span class="text-xs text-gray-400">最稳的 fallback 路径</span>
@@ -980,6 +1612,7 @@ onBeforeUnmount(() => {
<div <div
v-for="turn in activeTurnList" v-for="turn in activeTurnList"
:key="turn.id" :key="turn.id"
:id="`voice-turn-${turn.id}`"
class="rounded-2xl border border-gray-100 bg-gray-50 p-4" 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"> <div class="flex flex-wrap items-center gap-2 text-xs text-gray-400">