feat: refine voice studio attention workflow
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
"""Voice co-creation session APIs."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
@@ -82,6 +84,10 @@ async def list_voice_sessions(
|
||||
le=settings.voice_session_max_list_limit,
|
||||
),
|
||||
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),
|
||||
user: User = Depends(require_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -92,6 +98,8 @@ async def list_voice_sessions(
|
||||
db,
|
||||
limit=limit,
|
||||
active_only=active_only,
|
||||
needs_attention=needs_attention,
|
||||
attention_reason=attention_reason,
|
||||
active_first=active_first,
|
||||
)
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ class VoiceSessionSummaryResponse(BaseModel):
|
||||
latest_safety_message: str | None = None
|
||||
latest_assistant_audio_ready: bool = False
|
||||
last_turn_status: str | None = None
|
||||
attention_reasons: list[str] = Field(default_factory=list)
|
||||
transcription_mode_hint: str | None = None
|
||||
can_continue: bool = False
|
||||
can_finalize: bool = False
|
||||
@@ -149,6 +150,10 @@ class VoiceSessionAnalyticsResponse(BaseModel):
|
||||
|
||||
window_days: int | None = None
|
||||
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
|
||||
finalized_sessions: int = 0
|
||||
abandoned_sessions: int = 0
|
||||
|
||||
@@ -388,6 +388,12 @@ def _session_to_summary(
|
||||
story_patch=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(
|
||||
id=session.id,
|
||||
@@ -413,6 +419,7 @@ def _session_to_summary(
|
||||
session_audio_exists(latest_turn.assistant_audio_path) if latest_turn else False
|
||||
),
|
||||
last_turn_status=latest_turn.status if latest_turn else None,
|
||||
attention_reasons=attention_reasons,
|
||||
transcription_mode_hint=settings.voice_transcription_mode,
|
||||
can_continue=_session_can_continue(session),
|
||||
can_finalize=_can_finalize_with_latest_turn(session, latest_turn),
|
||||
@@ -422,6 +429,48 @@ def _session_to_summary(
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
async def _record_session_event(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -1082,6 +1131,8 @@ async def list_voice_sessions_service(
|
||||
*,
|
||||
limit: int | None = None,
|
||||
active_only: bool = False,
|
||||
needs_attention: bool = False,
|
||||
attention_reason: str | None = None,
|
||||
active_first: bool = False,
|
||||
) -> list[VoiceSessionSummaryResponse]:
|
||||
resolved_limit = limit or settings.voice_session_default_list_limit
|
||||
@@ -1102,19 +1153,20 @@ async def list_voice_sessions_service(
|
||||
)
|
||||
else:
|
||||
query = query.order_by(desc(VoiceSession.updated_at), desc(VoiceSession.created_at))
|
||||
if not needs_attention and attention_reason is None:
|
||||
query = query.limit(resolved_limit)
|
||||
|
||||
sessions = (await db.execute(query)).scalars().all()
|
||||
summaries: list[VoiceSessionSummaryResponse] = []
|
||||
for session in sessions:
|
||||
latest_turn = await _get_latest_turn(db, session_id=session.id)
|
||||
summaries.append(
|
||||
_session_to_summary(
|
||||
session,
|
||||
latest_turn=latest_turn,
|
||||
total_turns=session.current_turn_index,
|
||||
)
|
||||
)
|
||||
summary = await _build_session_summary(db, session)
|
||||
if needs_attention and not _session_summary_needs_attention(summary):
|
||||
continue
|
||||
if not _session_summary_matches_attention_reason(summary, attention_reason):
|
||||
continue
|
||||
summaries.append(summary)
|
||||
if (needs_attention or attention_reason is not None) and len(summaries) >= resolved_limit:
|
||||
break
|
||||
return summaries
|
||||
|
||||
|
||||
@@ -1134,12 +1186,7 @@ async def get_latest_active_voice_session_service(
|
||||
session = (await db.execute(query)).scalar_one_or_none()
|
||||
if session is None:
|
||||
return None
|
||||
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,
|
||||
)
|
||||
return await _build_session_summary(db, session)
|
||||
|
||||
|
||||
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()
|
||||
turns = (await db.execute(turn_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)
|
||||
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(
|
||||
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(
|
||||
window_days=days,
|
||||
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,
|
||||
finalized_sessions=finalized_sessions,
|
||||
abandoned_sessions=abandoned_sessions,
|
||||
|
||||
@@ -681,6 +681,149 @@ async def test_voice_session_analytics_summarize_failures_and_confirmations(
|
||||
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(
|
||||
db_session,
|
||||
auth_token,
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface VoiceSessionSummary {
|
||||
latest_safety_message: string | null
|
||||
latest_assistant_audio_ready: boolean
|
||||
last_turn_status: string | null
|
||||
attention_reasons: string[]
|
||||
transcription_mode_hint: string | null
|
||||
can_continue: boolean
|
||||
can_finalize: boolean
|
||||
@@ -81,6 +82,10 @@ export interface VoiceTurnAcceptedResponse {
|
||||
export interface VoiceSessionAnalytics {
|
||||
window_days: number | null
|
||||
total_sessions: number
|
||||
attention_sessions: number
|
||||
confirmation_attention_sessions: number
|
||||
safety_attention_sessions: number
|
||||
failed_attention_sessions: number
|
||||
active_sessions: number
|
||||
finalized_sessions: number
|
||||
abandoned_sessions: number
|
||||
|
||||
@@ -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