feat: polish voice studio workflow and bilingual copy
This commit is contained in:
@@ -102,7 +102,7 @@ const generationSteps = computed(() => {
|
||||
'Worker 会生成故事正文并保存主记录...',
|
||||
'主内容一可读就会自动跳转详情页...',
|
||||
'封面会继续在后台补全,失败也能重试...',
|
||||
'马上进入故事详情页。',
|
||||
'稍后会自动进入故事详情页。',
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"title": "DreamWeaver",
|
||||
"navHome": "Home",
|
||||
"navMyStories": "My Stories",
|
||||
"navProfiles": "Profiles",
|
||||
"navUniverses": "Universes",
|
||||
"navAdmin": "Providers Admin"
|
||||
"navProfiles": "Child Profiles",
|
||||
"navUniverses": "Story Universe",
|
||||
"navAdmin": "Provider Management"
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "Weave magical",
|
||||
"heroTitleHighlight": "bedtime stories for your child",
|
||||
"heroSubtitle": "AI-powered personalized stories for children aged 3-8, making every bedtime magical",
|
||||
"heroSubtitle": "AI-powered personalized stories for children ages 3-8, making every bedtime feel magical",
|
||||
"heroCta": "Start Creating",
|
||||
"heroCtaSecondary": "Learn More",
|
||||
"heroPreviewTitle": "Bunny's Brave Adventure",
|
||||
@@ -25,15 +25,15 @@
|
||||
"feature1Title": "AI-Powered Creation",
|
||||
"feature1Desc": "Enter a few keywords, and AI instantly creates an imaginative original story for your child",
|
||||
"feature2Title": "Personalized Memory",
|
||||
"feature2Desc": "The system remembers your child's preferences and growth, making stories more tailored over time",
|
||||
"feature2Desc": "The system remembers your child's preferences and growth, so stories feel more personal over time",
|
||||
"feature3Title": "Beautiful AI Illustrations",
|
||||
"feature3Desc": "Automatically generate unique cover illustrations for each story, bringing them to life",
|
||||
"feature4Title": "Warm Voice Narration",
|
||||
"feature4Desc": "Professional AI narration with a warm voice to accompany your child into sweet dreams",
|
||||
"feature5Title": "Educational Themes",
|
||||
"feature5Desc": "Courage, friendship, sharing, honesty... naturally weaving positive values into stories",
|
||||
"feature5Desc": "Themes like courage, friendship, sharing, and honesty are woven naturally into every story",
|
||||
"feature6Title": "Story Universe",
|
||||
"feature6Desc": "Create your own world where beloved characters continue their adventures across stories",
|
||||
"feature6Desc": "Create a shared story world where beloved characters can keep adventuring across stories",
|
||||
|
||||
"howItWorksTitle": "How It Works",
|
||||
"howItWorksSubtitle": "Four steps to start your magical story journey",
|
||||
@@ -67,30 +67,30 @@
|
||||
|
||||
"faqTitle": "Frequently Asked Questions",
|
||||
"faq1Question": "What age is DreamWeaver suitable for?",
|
||||
"faq1Answer": "We're designed for children aged 3-8. Story content, language difficulty, and educational themes are all optimized for this age group.",
|
||||
"faq1Answer": "DreamWeaver is designed for children ages 3-8. Story content, language level, and educational themes are all tuned for this age group.",
|
||||
"faq2Question": "Are the generated stories safe?",
|
||||
"faq2Answer": "Absolutely safe. All stories go through content filtering to ensure they're appropriate for children and convey positive values.",
|
||||
"faq2Answer": "All generated stories go through safety filters to help keep them appropriate for children and aligned with positive values.",
|
||||
"faq3Question": "Can I customize story characters?",
|
||||
"faq3Answer": "Yes! You can set preferences in your child's profile, or specify character names and traits when creating. AI will incorporate them into the story.",
|
||||
"faq4Question": "Will stories repeat?",
|
||||
"faq4Answer": "No. Every story is originally generated by AI in real-time. Even with the same keywords, you'll get different stories each time.",
|
||||
"faq5Question": "What languages are supported?",
|
||||
"faq5Answer": "Currently we support Chinese and English. You can switch interface language anytime, and stories will adjust accordingly.",
|
||||
"faq5Answer": "We currently support Chinese and English. You can switch the interface language at any time, and stories will adjust accordingly.",
|
||||
|
||||
"ctaTitle": "Ready to Create Magic for Your Child?",
|
||||
"ctaSubtitle": "Start now and let AI weave unique stories for your child's growth",
|
||||
"ctaButton": "Start Creating Free",
|
||||
"ctaTitle": "Ready to Create Something Magical for Your Child?",
|
||||
"ctaSubtitle": "Start now and let AI weave a one-of-a-kind story for your child's growth",
|
||||
"ctaButton": "Start Creating for Free",
|
||||
"ctaNote": "No credit card required",
|
||||
|
||||
"createModalTitle": "Create New Story",
|
||||
"inputTypeKeywords": "Keywords",
|
||||
"inputTypeStory": "Polish Story",
|
||||
"inputTypeKeywords": "Create from Keywords",
|
||||
"inputTypeStory": "Refine a Story",
|
||||
"selectProfile": "Select Child Profile",
|
||||
"selectProfileOptional": "(Optional)",
|
||||
"selectUniverse": "Select Story Universe",
|
||||
"noProfile": "No profile",
|
||||
"noUniverse": "No universe",
|
||||
"noUniverseHint": "No universe for this profile yet. Create one in Story Universe.",
|
||||
"noUniverseHint": "This profile doesn't have a story universe yet. Create one in Story Universe.",
|
||||
"inputLabel": "Enter Keywords",
|
||||
"inputLabelStory": "Enter Your Story",
|
||||
"inputPlaceholder": "e.g., bunny, forest, courage, friendship...",
|
||||
@@ -107,14 +107,14 @@
|
||||
"errorEmpty": "Please enter content",
|
||||
"errorLogin": "Please log in first",
|
||||
"generating": "Weaving your story...",
|
||||
"loginFirst": "Please Login",
|
||||
"startCreate": "Create Magic Story"
|
||||
"loginFirst": "Please log in",
|
||||
"startCreate": "Create Story"
|
||||
},
|
||||
"stories": {
|
||||
"myStories": "My Stories",
|
||||
"view": "View",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Are you sure to delete this story?",
|
||||
"confirmDelete": "Are you sure you want to delete this story?",
|
||||
"noStories": "No stories yet."
|
||||
},
|
||||
"storyDetail": {
|
||||
@@ -122,7 +122,7 @@
|
||||
"generateImage": "Generate Cover",
|
||||
"playAudio": "Play Audio",
|
||||
"modeGenerated": "Generated",
|
||||
"modeEnhanced": "Enhanced"
|
||||
"modeEnhanced": "Refined"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Provider Management",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"feature5Title": "教育主题融入",
|
||||
"feature5Desc": "勇气、友谊、分享、诚实...在故事中自然传递正向价值观",
|
||||
"feature6Title": "故事宇宙",
|
||||
"feature6Desc": "创建专属世界观,让喜爱的角色在不同故事中持续冒险",
|
||||
"feature6Desc": "创建专属故事宇宙,让喜爱的角色在不同故事中持续冒险",
|
||||
|
||||
"howItWorksTitle": "如何使用",
|
||||
"howItWorksSubtitle": "四步开启奇妙故事之旅",
|
||||
@@ -69,7 +69,7 @@
|
||||
"faq1Question": "梦语织机适合多大的孩子?",
|
||||
"faq1Answer": "我们专为 3-8 岁儿童设计,故事内容、语言难度和教育主题都针对这个年龄段优化。",
|
||||
"faq2Question": "生成的故事安全吗?",
|
||||
"faq2Answer": "绝对安全。所有故事都经过内容过滤,确保适合儿童阅读,传递积极正向的价值观。",
|
||||
"faq2Answer": "所有生成内容都会经过安全过滤,以更好地确保适合儿童阅读,并传递积极正向的价值观。",
|
||||
"faq3Question": "可以自定义故事角色吗?",
|
||||
"faq3Answer": "可以!您可以在孩子档案中设置喜好,或在创作时指定角色名称、特点,AI 会将其融入故事。",
|
||||
"faq4Question": "故事会重复吗?",
|
||||
@@ -77,7 +77,7 @@
|
||||
"faq5Question": "支持哪些语言?",
|
||||
"faq5Answer": "目前支持中文和英文,您可以随时切换界面语言,故事也会相应调整。",
|
||||
|
||||
"ctaTitle": "准备好为孩子创造魔法了吗?",
|
||||
"ctaTitle": "准备好为孩子创作奇妙故事了吗?",
|
||||
"ctaSubtitle": "立即开始,让 AI 为您的孩子编织独一无二的成长故事",
|
||||
"ctaButton": "免费开始创作",
|
||||
"ctaNote": "无需信用卡,立即体验",
|
||||
@@ -93,7 +93,7 @@
|
||||
"noUniverseHint": "当前档案暂无宇宙,可在「故事宇宙」中创建",
|
||||
"inputLabel": "输入关键词",
|
||||
"inputLabelStory": "输入您的故事",
|
||||
"inputPlaceholder": "例如:小兔子, 森林, 勇气, 友谊...",
|
||||
"inputPlaceholder": "例如:小兔子、森林、勇气、友谊……",
|
||||
"inputPlaceholderStory": "在这里输入您想要润色的故事...",
|
||||
"themeLabel": "选择教育主题",
|
||||
"themeOptional": "(可选)",
|
||||
@@ -108,14 +108,14 @@
|
||||
"errorLogin": "请先登录",
|
||||
"generating": "正在编织故事...",
|
||||
"loginFirst": "请先登录",
|
||||
"startCreate": "开始创作魔法故事"
|
||||
"startCreate": "开始创作"
|
||||
},
|
||||
"stories": {
|
||||
"myStories": "我的故事",
|
||||
"view": "查看",
|
||||
"delete": "删除",
|
||||
"confirmDelete": "确定删除这个故事吗?",
|
||||
"noStories": "暂无故事。"
|
||||
"noStories": "还没有故事。"
|
||||
},
|
||||
"storyDetail": {
|
||||
"back": "返回",
|
||||
@@ -136,12 +136,12 @@
|
||||
"type": "类型",
|
||||
"adapter": "适配器",
|
||||
"model": "模型",
|
||||
"apiBase": "API Base",
|
||||
"timeout": "超时 (ms)",
|
||||
"apiBase": "API 地址",
|
||||
"timeout": "超时(ms)",
|
||||
"retries": "最大重试",
|
||||
"weight": "权重",
|
||||
"priority": "优先级",
|
||||
"configRef": "Config Ref",
|
||||
"configRef": "配置引用",
|
||||
"enabled": "启用",
|
||||
"actions": "操作"
|
||||
},
|
||||
|
||||
@@ -288,7 +288,7 @@
|
||||
:key="p"
|
||||
@click="cloneDefault(type, p)"
|
||||
class="px-2 py-1 text-xs bg-white border border-gray-200 rounded text-gray-600 font-mono hover:border-indigo-300 hover:text-indigo-600 hover:shadow-sm transition-all cursor-pointer"
|
||||
title="点击基于此默认配置创建"
|
||||
title="基于此默认配置创建"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
|
||||
@@ -135,7 +135,7 @@ onMounted(fetchTimeline)
|
||||
<!-- 暂无数据 -->
|
||||
<div v-if="events.length === 0" class="text-center py-20 bg-white/50 backdrop-blur rounded-3xl border border-white">
|
||||
<SparklesIcon class="h-16 w-16 text-purple-300 mx-auto mb-4" />
|
||||
<p class="text-xl text-gray-500">还没有开始冒险呢,快去创作第一个故事吧!</p>
|
||||
<p class="text-xl text-gray-500">还没有开始冒险呢,先来创作第一个故事吧!</p>
|
||||
</div>
|
||||
|
||||
<!-- 时间轴内容 -->
|
||||
|
||||
@@ -102,7 +102,7 @@ const generationSteps = computed(() => {
|
||||
'Worker 会生成故事正文并保存主记录...',
|
||||
'主内容一可读就会自动跳转详情页...',
|
||||
'封面会继续在后台补全,失败也能重试...',
|
||||
'马上进入故事详情页。',
|
||||
'稍后会自动进入故事详情页。',
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"title": "DreamWeaver",
|
||||
"navHome": "Home",
|
||||
"navMyStories": "My Stories",
|
||||
"navProfiles": "Profiles",
|
||||
"navUniverses": "Universes",
|
||||
"navAdmin": "Providers Admin"
|
||||
"navProfiles": "Child Profiles",
|
||||
"navUniverses": "Story Universe",
|
||||
"navAdmin": "Provider Management"
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "Weave magical",
|
||||
"heroTitleHighlight": "bedtime stories for your child",
|
||||
"heroSubtitle": "AI-powered personalized stories for children aged 3-8, making every bedtime magical",
|
||||
"heroSubtitle": "AI-powered personalized stories for children ages 3-8, making every bedtime feel magical",
|
||||
"heroCta": "Start Creating",
|
||||
"heroCtaSecondary": "Learn More",
|
||||
"heroPreviewTitle": "Bunny's Brave Adventure",
|
||||
@@ -25,15 +25,15 @@
|
||||
"feature1Title": "AI-Powered Creation",
|
||||
"feature1Desc": "Enter a few keywords, and AI instantly creates an imaginative original story for your child",
|
||||
"feature2Title": "Personalized Memory",
|
||||
"feature2Desc": "The system remembers your child's preferences and growth, making stories more tailored over time",
|
||||
"feature2Desc": "The system remembers your child's preferences and growth, so stories feel more personal over time",
|
||||
"feature3Title": "Beautiful AI Illustrations",
|
||||
"feature3Desc": "Automatically generate unique cover illustrations for each story, bringing them to life",
|
||||
"feature4Title": "Warm Voice Narration",
|
||||
"feature4Desc": "Professional AI narration with a warm voice to accompany your child into sweet dreams",
|
||||
"feature5Title": "Educational Themes",
|
||||
"feature5Desc": "Courage, friendship, sharing, honesty... naturally weaving positive values into stories",
|
||||
"feature5Desc": "Themes like courage, friendship, sharing, and honesty are woven naturally into every story",
|
||||
"feature6Title": "Story Universe",
|
||||
"feature6Desc": "Create your own world where beloved characters continue their adventures across stories",
|
||||
"feature6Desc": "Create a shared story world where beloved characters can keep adventuring across stories",
|
||||
|
||||
"howItWorksTitle": "How It Works",
|
||||
"howItWorksSubtitle": "Four steps to start your magical story journey",
|
||||
@@ -67,30 +67,30 @@
|
||||
|
||||
"faqTitle": "Frequently Asked Questions",
|
||||
"faq1Question": "What age is DreamWeaver suitable for?",
|
||||
"faq1Answer": "We're designed for children aged 3-8. Story content, language difficulty, and educational themes are all optimized for this age group.",
|
||||
"faq1Answer": "DreamWeaver is designed for children ages 3-8. Story content, language level, and educational themes are all tuned for this age group.",
|
||||
"faq2Question": "Are the generated stories safe?",
|
||||
"faq2Answer": "Absolutely safe. All stories go through content filtering to ensure they're appropriate for children and convey positive values.",
|
||||
"faq2Answer": "All generated stories go through safety filters to help keep them appropriate for children and aligned with positive values.",
|
||||
"faq3Question": "Can I customize story characters?",
|
||||
"faq3Answer": "Yes! You can set preferences in your child's profile, or specify character names and traits when creating. AI will incorporate them into the story.",
|
||||
"faq4Question": "Will stories repeat?",
|
||||
"faq4Answer": "No. Every story is originally generated by AI in real-time. Even with the same keywords, you'll get different stories each time.",
|
||||
"faq5Question": "What languages are supported?",
|
||||
"faq5Answer": "Currently we support Chinese and English. You can switch interface language anytime, and stories will adjust accordingly.",
|
||||
"faq5Answer": "We currently support Chinese and English. You can switch the interface language at any time, and stories will adjust accordingly.",
|
||||
|
||||
"ctaTitle": "Ready to Create Magic for Your Child?",
|
||||
"ctaSubtitle": "Start now and let AI weave unique stories for your child's growth",
|
||||
"ctaButton": "Start Creating Free",
|
||||
"ctaTitle": "Ready to Create Something Magical for Your Child?",
|
||||
"ctaSubtitle": "Start now and let AI weave a one-of-a-kind story for your child's growth",
|
||||
"ctaButton": "Start Creating for Free",
|
||||
"ctaNote": "No credit card required",
|
||||
|
||||
"createModalTitle": "Create New Story",
|
||||
"inputTypeKeywords": "Keywords",
|
||||
"inputTypeStory": "Polish Story",
|
||||
"inputTypeKeywords": "Create from Keywords",
|
||||
"inputTypeStory": "Refine a Story",
|
||||
"selectProfile": "Select Child Profile",
|
||||
"selectProfileOptional": "(Optional)",
|
||||
"selectUniverse": "Select Story Universe",
|
||||
"noProfile": "No profile",
|
||||
"noUniverse": "No universe",
|
||||
"noUniverseHint": "No universe for this profile yet. Create one in Story Universe.",
|
||||
"noUniverseHint": "This profile doesn't have a story universe yet. Create one in Story Universe.",
|
||||
"inputLabel": "Enter Keywords",
|
||||
"inputLabelStory": "Enter Your Story",
|
||||
"inputPlaceholder": "e.g., bunny, forest, courage, friendship...",
|
||||
@@ -107,14 +107,14 @@
|
||||
"errorEmpty": "Please enter content",
|
||||
"errorLogin": "Please log in first",
|
||||
"generating": "Weaving your story...",
|
||||
"loginFirst": "Please Login",
|
||||
"startCreate": "Create Magic Story"
|
||||
"loginFirst": "Please log in",
|
||||
"startCreate": "Create Story"
|
||||
},
|
||||
"stories": {
|
||||
"myStories": "My Stories",
|
||||
"view": "View",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Are you sure to delete this story?",
|
||||
"confirmDelete": "Are you sure you want to delete this story?",
|
||||
"noStories": "No stories yet."
|
||||
},
|
||||
"storyDetail": {
|
||||
@@ -122,7 +122,7 @@
|
||||
"generateImage": "Generate Cover",
|
||||
"playAudio": "Play Audio",
|
||||
"modeGenerated": "Generated",
|
||||
"modeEnhanced": "Enhanced"
|
||||
"modeEnhanced": "Refined"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Provider Management",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"feature5Title": "教育主题融入",
|
||||
"feature5Desc": "勇气、友谊、分享、诚实...在故事中自然传递正向价值观",
|
||||
"feature6Title": "故事宇宙",
|
||||
"feature6Desc": "创建专属世界观,让喜爱的角色在不同故事中持续冒险",
|
||||
"feature6Desc": "创建专属故事宇宙,让喜爱的角色在不同故事中持续冒险",
|
||||
|
||||
"howItWorksTitle": "如何使用",
|
||||
"howItWorksSubtitle": "四步开启奇妙故事之旅",
|
||||
@@ -69,7 +69,7 @@
|
||||
"faq1Question": "梦语织机适合多大的孩子?",
|
||||
"faq1Answer": "我们专为 3-8 岁儿童设计,故事内容、语言难度和教育主题都针对这个年龄段优化。",
|
||||
"faq2Question": "生成的故事安全吗?",
|
||||
"faq2Answer": "绝对安全。所有故事都经过内容过滤,确保适合儿童阅读,传递积极正向的价值观。",
|
||||
"faq2Answer": "所有生成内容都会经过安全过滤,以更好地确保适合儿童阅读,并传递积极正向的价值观。",
|
||||
"faq3Question": "可以自定义故事角色吗?",
|
||||
"faq3Answer": "可以!您可以在孩子档案中设置喜好,或在创作时指定角色名称、特点,AI 会将其融入故事。",
|
||||
"faq4Question": "故事会重复吗?",
|
||||
@@ -77,7 +77,7 @@
|
||||
"faq5Question": "支持哪些语言?",
|
||||
"faq5Answer": "目前支持中文和英文,您可以随时切换界面语言,故事也会相应调整。",
|
||||
|
||||
"ctaTitle": "准备好为孩子创造魔法了吗?",
|
||||
"ctaTitle": "准备好为孩子创作奇妙故事了吗?",
|
||||
"ctaSubtitle": "立即开始,让 AI 为您的孩子编织独一无二的成长故事",
|
||||
"ctaButton": "免费开始创作",
|
||||
"ctaNote": "无需信用卡,立即体验",
|
||||
@@ -93,7 +93,7 @@
|
||||
"noUniverseHint": "当前档案暂无宇宙,可在「故事宇宙」中创建",
|
||||
"inputLabel": "输入关键词",
|
||||
"inputLabelStory": "输入您的故事",
|
||||
"inputPlaceholder": "例如:小兔子, 森林, 勇气, 友谊...",
|
||||
"inputPlaceholder": "例如:小兔子、森林、勇气、友谊……",
|
||||
"inputPlaceholderStory": "在这里输入您想要润色的故事...",
|
||||
"themeLabel": "选择教育主题",
|
||||
"themeOptional": "(可选)",
|
||||
@@ -108,14 +108,14 @@
|
||||
"errorLogin": "请先登录",
|
||||
"generating": "正在编织故事...",
|
||||
"loginFirst": "请先登录",
|
||||
"startCreate": "开始创作魔法故事"
|
||||
"startCreate": "开始创作"
|
||||
},
|
||||
"stories": {
|
||||
"myStories": "我的故事",
|
||||
"view": "查看",
|
||||
"delete": "删除",
|
||||
"confirmDelete": "确定删除这个故事吗?",
|
||||
"noStories": "暂无故事。"
|
||||
"noStories": "还没有故事。"
|
||||
},
|
||||
"storyDetail": {
|
||||
"back": "返回",
|
||||
@@ -136,12 +136,12 @@
|
||||
"type": "类型",
|
||||
"adapter": "适配器",
|
||||
"model": "模型",
|
||||
"apiBase": "API Base",
|
||||
"timeout": "超时 (ms)",
|
||||
"apiBase": "API 地址",
|
||||
"timeout": "超时(ms)",
|
||||
"retries": "最大重试",
|
||||
"weight": "权重",
|
||||
"priority": "优先级",
|
||||
"configRef": "Config Ref",
|
||||
"configRef": "配置引用",
|
||||
"enabled": "启用",
|
||||
"actions": "操作"
|
||||
},
|
||||
|
||||
131
frontend/src/utils/voiceSession.ts
Normal file
131
frontend/src/utils/voiceSession.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { VoiceSessionSummary } from '../types/voiceSession'
|
||||
|
||||
export type VoiceAttentionReason = 'pending_confirmation' | 'safety_intervention' | 'failed_turn'
|
||||
export type VoiceStudioFocusTarget = 'confirmation' | 'safety' | 'failed' | 'text'
|
||||
|
||||
export interface VoiceSessionNextStep {
|
||||
label: string
|
||||
description: string
|
||||
toneClass: string
|
||||
}
|
||||
|
||||
export interface VoiceSessionNextAction {
|
||||
label: string
|
||||
reason?: VoiceAttentionReason
|
||||
focus?: VoiceStudioFocusTarget
|
||||
storyId?: number
|
||||
}
|
||||
|
||||
export function getVoiceSessionNextStep(session: VoiceSessionSummary): VoiceSessionNextStep {
|
||||
if (session.attention_reasons.includes('pending_confirmation')) {
|
||||
return {
|
||||
label: '先确认本轮理解',
|
||||
description:
|
||||
session.latest_confirmation_message
|
||||
|| '系统对这一轮的理解不够确定,建议家长先确认后再继续。',
|
||||
toneClass: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
}
|
||||
}
|
||||
|
||||
if (session.attention_reasons.includes('failed_turn')) {
|
||||
return {
|
||||
label: '优先处理失败回合',
|
||||
description:
|
||||
session.last_error
|
||||
|| '最近一轮没有成功完成,建议先重试或改成文本重发。',
|
||||
toneClass: 'border-slate-200 bg-slate-100 text-slate-700',
|
||||
}
|
||||
}
|
||||
|
||||
if (session.attention_reasons.includes('safety_intervention')) {
|
||||
return {
|
||||
label: '换一种更温和的表达继续',
|
||||
description:
|
||||
session.latest_safety_message
|
||||
|| '当前内容被安全兜底拦住了,建议改写后再继续共创。',
|
||||
toneClass: 'border-rose-200 bg-rose-50 text-rose-700',
|
||||
}
|
||||
}
|
||||
|
||||
if (session.final_story_id) {
|
||||
return {
|
||||
label: '查看正式故事',
|
||||
description: '这场共创已经保存为正式故事,可以回看结果或继续补资源。',
|
||||
toneClass: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
}
|
||||
}
|
||||
|
||||
if (session.can_finalize) {
|
||||
return {
|
||||
label: '继续共创或保存当前版本',
|
||||
description: '可以继续讲下一轮,也可以把当前版本保存成正式故事。',
|
||||
toneClass: 'border-violet-200 bg-violet-50 text-violet-700',
|
||||
}
|
||||
}
|
||||
|
||||
if (session.can_continue) {
|
||||
return {
|
||||
label: '发送下一轮语音或文本',
|
||||
description: '本轮已完成,可以继续补充情节或修正走向。',
|
||||
toneClass: 'border-sky-200 bg-sky-50 text-sky-700',
|
||||
}
|
||||
}
|
||||
|
||||
if (session.status === 'draft') {
|
||||
return {
|
||||
label: '开始第一轮共创',
|
||||
description: '先说一句主题或角色,让故事正式开始。',
|
||||
toneClass: 'border-gray-200 bg-gray-50 text-gray-700',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: '先查看当前状态',
|
||||
description: '会话暂时不在可继续状态,建议先查看详情和最近事件。',
|
||||
toneClass: 'border-gray-200 bg-gray-50 text-gray-700',
|
||||
}
|
||||
}
|
||||
|
||||
export function getVoiceSessionNextAction(session: VoiceSessionSummary): VoiceSessionNextAction {
|
||||
if (session.attention_reasons.includes('pending_confirmation')) {
|
||||
return {
|
||||
label: '确认',
|
||||
reason: 'pending_confirmation',
|
||||
focus: 'confirmation',
|
||||
}
|
||||
}
|
||||
|
||||
if (session.attention_reasons.includes('failed_turn')) {
|
||||
return {
|
||||
label: '重试',
|
||||
reason: 'failed_turn',
|
||||
focus: 'failed',
|
||||
}
|
||||
}
|
||||
|
||||
if (session.attention_reasons.includes('safety_intervention')) {
|
||||
return {
|
||||
label: '改写',
|
||||
reason: 'safety_intervention',
|
||||
focus: 'text',
|
||||
}
|
||||
}
|
||||
|
||||
if (session.final_story_id) {
|
||||
return {
|
||||
label: '查看正式故事',
|
||||
storyId: session.final_story_id,
|
||||
}
|
||||
}
|
||||
|
||||
if (session.can_continue || session.can_finalize || session.status === 'draft') {
|
||||
return {
|
||||
label: '继续',
|
||||
focus: 'text',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: '查看详情',
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ onMounted(fetchTimeline)
|
||||
<!-- 暂无数据 -->
|
||||
<div v-if="events.length === 0" class="text-center py-20 bg-white/50 backdrop-blur rounded-3xl border border-white">
|
||||
<SparklesIcon class="h-16 w-16 text-purple-300 mx-auto mb-4" />
|
||||
<p class="text-xl text-gray-500">还没有开始冒险呢,快去创作第一个故事吧!</p>
|
||||
<p class="text-xl text-gray-500">还没有开始冒险呢,先来创作第一个故事吧!</p>
|
||||
</div>
|
||||
|
||||
<!-- 时间轴内容 -->
|
||||
|
||||
@@ -9,6 +9,10 @@ import EmptyState from '../components/ui/EmptyState.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import type { GenerationOpsSummary, GenerationProviderAnalytics } from '../types/generation'
|
||||
import type { VoiceSessionAnalytics, VoiceSessionSummary } from '../types/voiceSession'
|
||||
import {
|
||||
getVoiceSessionNextAction,
|
||||
getVoiceSessionNextStep,
|
||||
} from '../utils/voiceSession'
|
||||
import {
|
||||
getAssetStatusMeta,
|
||||
getGenerationStatusMeta,
|
||||
@@ -172,31 +176,16 @@ function continueActiveVoiceSession() {
|
||||
goToVoiceStudio()
|
||||
return
|
||||
}
|
||||
if (activeVoiceSession.value.latest_requires_confirmation) {
|
||||
goToVoiceStudio({
|
||||
reason: 'pending_confirmation',
|
||||
sessionId: activeVoiceSession.value.id,
|
||||
focus: 'confirmation',
|
||||
})
|
||||
const action = getVoiceSessionNextAction(activeVoiceSession.value)
|
||||
if (action.storyId) {
|
||||
router.push(`/story/${action.storyId}`)
|
||||
return
|
||||
}
|
||||
if (activeVoiceSession.value.latest_safety_message) {
|
||||
goToVoiceStudio({
|
||||
reason: 'safety_intervention',
|
||||
reason: action.reason,
|
||||
sessionId: activeVoiceSession.value.id,
|
||||
focus: 'safety',
|
||||
focus: action.focus,
|
||||
})
|
||||
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) {
|
||||
@@ -316,10 +305,21 @@ watch([selectedWindow, selectedCapability, selectedVoiceWindow], () => {
|
||||
>
|
||||
最近一轮触发了儿童内容安全兜底,建议回到工作台查看详细记录。
|
||||
</p>
|
||||
<div
|
||||
class="mt-3 rounded-xl border px-3 py-3 text-sm leading-6"
|
||||
:class="getVoiceSessionNextStep(activeVoiceSession).toneClass"
|
||||
>
|
||||
<div class="font-medium">
|
||||
建议动作:{{ getVoiceSessionNextStep(activeVoiceSession).label }}
|
||||
</div>
|
||||
<div class="mt-1 opacity-90">
|
||||
{{ getVoiceSessionNextStep(activeVoiceSession).description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BaseButton @click="continueActiveVoiceSession">
|
||||
<SparklesIcon class="h-5 w-5 mr-2" />
|
||||
继续语音共创
|
||||
{{ getVoiceSessionNextAction(activeVoiceSession).label }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
@@ -19,6 +19,10 @@ import BaseTextarea from '../components/ui/BaseTextarea.vue'
|
||||
import GenerationTrace from '../components/GenerationTrace.vue'
|
||||
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
|
||||
import EmptyState from '../components/ui/EmptyState.vue'
|
||||
import {
|
||||
getVoiceSessionNextAction,
|
||||
getVoiceSessionNextStep,
|
||||
} from '../utils/voiceSession'
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
BookOpenIcon,
|
||||
@@ -40,6 +44,24 @@ interface StoryUniverse {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface VoiceStoryStateSummary {
|
||||
premise: string | null
|
||||
latestDirection: string | null
|
||||
latestSegment: string | null
|
||||
segmentCount: number
|
||||
safetyFlags: string[]
|
||||
coverPromptReady: boolean
|
||||
}
|
||||
|
||||
interface VoiceStoryActionStep {
|
||||
key: 'confirm' | 'continue' | 'finalize' | 'review_result'
|
||||
title: string
|
||||
description: string
|
||||
status: 'done' | 'current' | 'upcoming'
|
||||
contextLabel?: string
|
||||
contextValue?: string | null
|
||||
}
|
||||
|
||||
type SessionFilter = 'active' | 'attention' | 'recent'
|
||||
type AttentionReasonFilter = 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn'
|
||||
type VoiceStudioFocusTarget = 'confirmation' | 'safety' | 'failed' | 'text'
|
||||
@@ -80,6 +102,7 @@ let recordingTimer: number | null = null
|
||||
let recordingStartedAt = 0
|
||||
let sessionPollTimer: number | null = null
|
||||
let autoAdvanceNoticeTimer: number | null = null
|
||||
let attentionCompletionNoticeTimer: number | null = null
|
||||
|
||||
const recordedBlob = ref<Blob | null>(null)
|
||||
const recordedAudioUrl = ref<string | null>(null)
|
||||
@@ -91,21 +114,7 @@ const universeOptions = computed(() =>
|
||||
universes.value.map((universe) => ({ value: universe.id, label: universe.name })),
|
||||
)
|
||||
const filteredSessions = computed(() => {
|
||||
switch (sessionFilter.value) {
|
||||
case 'active':
|
||||
return sessions.value.filter((session) => session.can_continue)
|
||||
case 'attention':
|
||||
return sessions.value.filter(
|
||||
(session) =>
|
||||
sessionNeedsAttention(session)
|
||||
&& (
|
||||
attentionReasonFilter.value === 'all'
|
||||
|| session.attention_reasons.includes(attentionReasonFilter.value)
|
||||
),
|
||||
)
|
||||
default:
|
||||
return sessions.value
|
||||
}
|
||||
return resolveDisplayedSessions(sessions.value)
|
||||
})
|
||||
|
||||
const activeTurnList = computed(() => activeSession.value?.recent_turns ?? [])
|
||||
@@ -129,6 +138,127 @@ const finalStorySummary = computed(() => {
|
||||
const value = activeSession.value?.story_state?.final_summary
|
||||
return typeof value === 'string' ? value : null
|
||||
})
|
||||
const storyStateSummary = computed<VoiceStoryStateSummary | null>(() => {
|
||||
if (!activeSession.value) return null
|
||||
const storyState = activeSession.value.story_state ?? {}
|
||||
const narrativeSegments = Array.isArray(storyState.narrative_segments)
|
||||
? storyState.narrative_segments.filter((segment): segment is string => typeof segment === 'string' && segment.trim().length > 0)
|
||||
: []
|
||||
const safetyFlags = Array.isArray(storyState.safety_flags)
|
||||
? storyState.safety_flags.filter((flag): flag is string => typeof flag === 'string' && flag.trim().length > 0)
|
||||
: []
|
||||
|
||||
return {
|
||||
premise: typeof storyState.premise === 'string' && storyState.premise.trim().length > 0
|
||||
? storyState.premise.trim()
|
||||
: null,
|
||||
latestDirection: typeof storyState.latest_direction === 'string' && storyState.latest_direction.trim().length > 0
|
||||
? storyState.latest_direction.trim()
|
||||
: null,
|
||||
latestSegment: narrativeSegments.length > 0 ? narrativeSegments[narrativeSegments.length - 1] : null,
|
||||
segmentCount: narrativeSegments.length,
|
||||
safetyFlags,
|
||||
coverPromptReady: typeof storyState.cover_prompt === 'string' && storyState.cover_prompt.trim().length > 0,
|
||||
}
|
||||
})
|
||||
const storyActionSteps = computed<VoiceStoryActionStep[]>(() => {
|
||||
if (!activeSession.value || !storyStateSummary.value) return []
|
||||
|
||||
const steps: VoiceStoryActionStep[] = []
|
||||
const isFinalized = Boolean(activeSession.value.final_story_id)
|
||||
|
||||
steps.push({
|
||||
key: 'confirm',
|
||||
title: '确认这一轮理解',
|
||||
description: hasPendingConfirmation.value
|
||||
? '这一轮还在等待家长确认,建议先确认系统理解后再继续。'
|
||||
: activeSession.value.total_turns > 0
|
||||
? '当前没有待确认回合,系统已经可以按最近理解继续往下讲。'
|
||||
: '等第一轮输入进来后,这里会显示系统是否需要家长确认。',
|
||||
status: hasPendingConfirmation.value
|
||||
? 'current'
|
||||
: activeSession.value.total_turns > 0
|
||||
? 'done'
|
||||
: 'upcoming',
|
||||
contextLabel: hasPendingConfirmation.value ? '当前系统理解' : undefined,
|
||||
contextValue: hasPendingConfirmation.value
|
||||
? activeSession.value.latest_understanding_summary || activeSession.value.latest_confirmation_message
|
||||
: null,
|
||||
})
|
||||
|
||||
steps.push({
|
||||
key: 'continue',
|
||||
title: '继续推进故事',
|
||||
description: activeSession.value.latest_safety_message
|
||||
? '建议先换一种更温和的表达,再继续补下一段故事。'
|
||||
: storyStateSummary.value.segmentCount > 0
|
||||
? `当前已经生成 ${storyStateSummary.value.segmentCount} 段故事,可以继续补下一轮或修正走向。`
|
||||
: '第一段故事生成后,这里会提示可以继续补下一轮。',
|
||||
status: isFinalized
|
||||
? 'done'
|
||||
: !hasPendingConfirmation.value && activeSession.value.can_continue
|
||||
? 'current'
|
||||
: storyStateSummary.value.segmentCount > 0
|
||||
? 'done'
|
||||
: 'upcoming',
|
||||
contextLabel:
|
||||
!isFinalized && !hasPendingConfirmation.value && activeSession.value.can_continue
|
||||
? activeSession.value.latest_safety_message
|
||||
? '建议改写方向'
|
||||
: storyStateSummary.value.latestDirection
|
||||
? '最新改写方向'
|
||||
: storyStateSummary.value.latestSegment
|
||||
? '最近一段故事'
|
||||
: undefined
|
||||
: undefined,
|
||||
contextValue:
|
||||
!isFinalized && !hasPendingConfirmation.value && activeSession.value.can_continue
|
||||
? activeSession.value.latest_safety_message
|
||||
|| storyStateSummary.value.latestDirection
|
||||
|| truncateNarrative(storyStateSummary.value.latestSegment, 110)
|
||||
: null,
|
||||
})
|
||||
|
||||
steps.push({
|
||||
key: 'finalize',
|
||||
title: '保存当前版本',
|
||||
description: isFinalized
|
||||
? '当前版本已经沉淀为正式故事。'
|
||||
: activeSession.value.can_finalize
|
||||
? '当前内容已经足够完整,可以随时保存成正式故事。'
|
||||
: '再多完成一段稳定内容后,就可以保存为正式故事。',
|
||||
status: isFinalized
|
||||
? 'done'
|
||||
: activeSession.value.can_finalize
|
||||
? 'current'
|
||||
: 'upcoming',
|
||||
contextLabel:
|
||||
!isFinalized && activeSession.value.can_finalize
|
||||
? '保存后内容预览'
|
||||
: undefined,
|
||||
contextValue:
|
||||
!isFinalized && activeSession.value.can_finalize
|
||||
? finalStorySummary.value || truncateNarrative(storyStateSummary.value.latestSegment, 110)
|
||||
: null,
|
||||
})
|
||||
|
||||
steps.push({
|
||||
key: 'review_result',
|
||||
title: '查看正式故事与后续资源',
|
||||
description: isFinalized
|
||||
? '现在可以查看正式故事回看结果,也可以等待封面等后续资源继续补齐。'
|
||||
: '保存完成后,这里会接上正式故事查看和资源补全过程。',
|
||||
status: isFinalized ? 'current' : 'upcoming',
|
||||
contextLabel: isFinalized ? '正式故事状态' : undefined,
|
||||
contextValue: isFinalized
|
||||
? finalStorySummary.value
|
||||
? `故事 #${activeSession.value.final_story_id} · ${finalStorySummary.value}`
|
||||
: `故事 #${activeSession.value.final_story_id}`
|
||||
: null,
|
||||
})
|
||||
|
||||
return steps
|
||||
})
|
||||
const finalStoryId = computed(() => activeSession.value?.final_story_id ?? null)
|
||||
const finalStoryHasAssetWork = computed(() => Boolean(finalStoryId.value))
|
||||
const turnSuccessRateLabel = computed(() => {
|
||||
@@ -166,6 +296,10 @@ const autoAdvanceNotice = ref<{
|
||||
toTitle: string
|
||||
reasonLabel: string
|
||||
} | null>(null)
|
||||
const attentionCompletionNotice = ref<{
|
||||
completedReason: AttentionReasonFilter
|
||||
completedReasonLabel: string
|
||||
} | null>(null)
|
||||
|
||||
function formatSessionStatus(status: string) {
|
||||
switch (status) {
|
||||
@@ -238,6 +372,82 @@ function formatConfidence(value: number | null | undefined) {
|
||||
return `${Math.round(value * 100)}%`
|
||||
}
|
||||
|
||||
function truncateNarrative(value: string | null, maxLength = 90) {
|
||||
if (!value) return null
|
||||
const normalized = value.replace(/\s+/g, ' ').trim()
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized
|
||||
}
|
||||
return `${normalized.slice(0, maxLength).trim()}...`
|
||||
}
|
||||
|
||||
function getStoryActionStepClass(status: VoiceStoryActionStep['status']) {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return {
|
||||
badge: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
dot: 'bg-emerald-500',
|
||||
}
|
||||
case 'current':
|
||||
return {
|
||||
badge: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
dot: 'bg-amber-500',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
badge: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
dot: 'bg-gray-300',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToElement(elementId: string) {
|
||||
void nextTick(() => {
|
||||
document.getElementById(elementId)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function handleStoryActionStep(step: VoiceStoryActionStep) {
|
||||
if (step.status !== 'current') return
|
||||
|
||||
switch (step.key) {
|
||||
case 'confirm':
|
||||
scrollToElement('voice-attention-confirmation-card')
|
||||
return
|
||||
case 'continue':
|
||||
jumpToTextTurnComposer('')
|
||||
return
|
||||
case 'finalize':
|
||||
void finalizeSession()
|
||||
return
|
||||
case 'review_result':
|
||||
viewFinalStory()
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function getStoryActionStepButtonLabel(step: VoiceStoryActionStep) {
|
||||
if (step.status !== 'current') return null
|
||||
|
||||
switch (step.key) {
|
||||
case 'confirm':
|
||||
return '确认'
|
||||
case 'continue':
|
||||
return activeSession.value?.latest_safety_message ? '改写' : '继续'
|
||||
case 'finalize':
|
||||
return '保存当前版本'
|
||||
case 'review_result':
|
||||
return '查看正式故事'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatAnalyticsWindowLabel(windowDays: number | null | undefined) {
|
||||
if (typeof windowDays === 'number') {
|
||||
return `最近 ${windowDays} 天`
|
||||
@@ -274,6 +484,59 @@ function formatAttentionReason(reason: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionUpdatedAtValue(session: VoiceSessionSummary) {
|
||||
const value = Date.parse(session.updated_at)
|
||||
return Number.isNaN(value) ? 0 : value
|
||||
}
|
||||
|
||||
function getAttentionPriority(session: VoiceSessionSummary) {
|
||||
if (session.attention_reasons.includes('pending_confirmation')) {
|
||||
return 0
|
||||
}
|
||||
if (session.attention_reasons.includes('failed_turn')) {
|
||||
return 1
|
||||
}
|
||||
if (session.attention_reasons.includes('safety_intervention')) {
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
function sortDisplayedSessions(list: VoiceSessionSummary[]) {
|
||||
return [...list].sort((left, right) => {
|
||||
if (sessionFilter.value === 'attention') {
|
||||
const priorityDiff = getAttentionPriority(left) - getAttentionPriority(right)
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff
|
||||
}
|
||||
}
|
||||
return getSessionUpdatedAtValue(right) - getSessionUpdatedAtValue(left)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveDisplayedSessions(sourceSessions: VoiceSessionSummary[]) {
|
||||
let visibleSessions: VoiceSessionSummary[]
|
||||
switch (sessionFilter.value) {
|
||||
case 'active':
|
||||
visibleSessions = sourceSessions.filter((session) => session.can_continue)
|
||||
break
|
||||
case 'attention':
|
||||
visibleSessions = sourceSessions.filter(
|
||||
(session) =>
|
||||
sessionNeedsAttention(session)
|
||||
&& (
|
||||
attentionReasonFilter.value === 'all'
|
||||
|| session.attention_reasons.includes(attentionReasonFilter.value)
|
||||
),
|
||||
)
|
||||
break
|
||||
default:
|
||||
visibleSessions = sourceSessions
|
||||
break
|
||||
}
|
||||
return sortDisplayedSessions(visibleSessions)
|
||||
}
|
||||
|
||||
function parseSessionFilter(value: unknown): SessionFilter | null {
|
||||
if (value === 'active' || value === 'attention' || value === 'recent') {
|
||||
return value
|
||||
@@ -302,6 +565,7 @@ function parseFocusTarget(value: unknown): VoiceStudioFocusTarget | null {
|
||||
|
||||
function applyRouteState() {
|
||||
suppressAutoAdvanceNotice.value = true
|
||||
clearAttentionCompletionNotice()
|
||||
sessionFilter.value = parseSessionFilter(route.query.filter) ?? 'active'
|
||||
attentionReasonFilter.value = parseAttentionReasonFilter(route.query.reason) ?? 'all'
|
||||
pendingFocusTarget.value = parseFocusTarget(route.query.focus)
|
||||
@@ -414,6 +678,14 @@ function clearAutoAdvanceNotice() {
|
||||
autoAdvanceNotice.value = null
|
||||
}
|
||||
|
||||
function clearAttentionCompletionNotice() {
|
||||
if (attentionCompletionNoticeTimer) {
|
||||
window.clearTimeout(attentionCompletionNoticeTimer)
|
||||
attentionCompletionNoticeTimer = null
|
||||
}
|
||||
attentionCompletionNotice.value = null
|
||||
}
|
||||
|
||||
function showAutoAdvanceNotice(fromTitle: string, toTitle: string) {
|
||||
clearAutoAdvanceNotice()
|
||||
autoAdvanceNotice.value = {
|
||||
@@ -430,6 +702,18 @@ function showAutoAdvanceNotice(fromTitle: string, toTitle: string) {
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function showAttentionCompletionNotice(completedReason: AttentionReasonFilter) {
|
||||
clearAttentionCompletionNotice()
|
||||
attentionCompletionNotice.value = {
|
||||
completedReason,
|
||||
completedReasonLabel: formatAttentionReasonTitle(completedReason),
|
||||
}
|
||||
attentionCompletionNoticeTimer = window.setTimeout(() => {
|
||||
attentionCompletionNotice.value = null
|
||||
attentionCompletionNoticeTimer = null
|
||||
}, 6000)
|
||||
}
|
||||
|
||||
async function fetchProfiles() {
|
||||
if (!userStore.user) return
|
||||
const data = await api.get<{ profiles: ChildProfile[] }>('/api/profiles')
|
||||
@@ -458,20 +742,21 @@ async function loadSessions() {
|
||||
try {
|
||||
const previousActiveSession = activeSession.value
|
||||
sessions.value = await api.get<VoiceSessionSummary[]>(buildVoiceSessionListPath())
|
||||
const displayedSessions = resolveDisplayedSessions(sessions.value)
|
||||
if (
|
||||
(requestedSessionId.value || pendingFocusTarget.value)
|
||||
&& requestedSessionId.value
|
||||
&& !sessions.value.some((item) => item.id === requestedSessionId.value)
|
||||
&& !displayedSessions.some((item) => item.id === requestedSessionId.value)
|
||||
) {
|
||||
void syncVoiceStudioRouteState()
|
||||
}
|
||||
const preferredSession = (
|
||||
requestedSessionId.value
|
||||
? sessions.value.find((item) => item.id === requestedSessionId.value)
|
||||
? displayedSessions.find((item) => item.id === requestedSessionId.value)
|
||||
: null
|
||||
) ?? sessions.value.find((item) => item.can_continue) ?? sessions.value[0]
|
||||
) ?? displayedSessions.find((item) => item.can_continue) ?? displayedSessions[0]
|
||||
const currentSessionStillVisible = activeSession.value
|
||||
? sessions.value.some((item) => item.id === activeSession.value?.id)
|
||||
? displayedSessions.some((item) => item.id === activeSession.value?.id)
|
||||
: false
|
||||
if (
|
||||
!activeSession.value
|
||||
@@ -497,6 +782,14 @@ async function loadSessions() {
|
||||
}
|
||||
await loadSessionDetail(preferredSession.id)
|
||||
} else if (sessionFilter.value !== 'recent') {
|
||||
if (
|
||||
sessionFilter.value === 'attention'
|
||||
&& previousActiveSession
|
||||
&& !currentSessionStillVisible
|
||||
&& !suppressAutoAdvanceNotice.value
|
||||
) {
|
||||
showAttentionCompletionNotice(attentionReasonFilter.value)
|
||||
}
|
||||
activeSession.value = null
|
||||
}
|
||||
}
|
||||
@@ -812,12 +1105,23 @@ function viewFinalStory() {
|
||||
router.push(`/story/${activeSession.value.final_story_id}`)
|
||||
}
|
||||
|
||||
async function openSessionQuickAction(session: VoiceSessionSummary) {
|
||||
const action = getVoiceSessionNextAction(session)
|
||||
if (action.storyId) {
|
||||
router.push(`/story/${action.storyId}`)
|
||||
return
|
||||
}
|
||||
pendingFocusTarget.value = action.focus ?? null
|
||||
await loadSessionDetail(session.id)
|
||||
}
|
||||
|
||||
function setAnalyticsWindow(value: '7' | '30' | 'all') {
|
||||
analyticsWindow.value = value
|
||||
}
|
||||
|
||||
function setSessionFilter(value: SessionFilter) {
|
||||
suppressAutoAdvanceNotice.value = true
|
||||
clearAttentionCompletionNotice()
|
||||
sessionFilter.value = value
|
||||
if (value !== 'attention') {
|
||||
attentionReasonFilter.value = 'all'
|
||||
@@ -840,6 +1144,7 @@ function setAttentionReasonFilter(
|
||||
value: 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn',
|
||||
) {
|
||||
suppressAutoAdvanceNotice.value = true
|
||||
clearAttentionCompletionNotice()
|
||||
attentionReasonFilter.value = value
|
||||
void syncVoiceStudioRouteState()
|
||||
}
|
||||
@@ -852,6 +1157,17 @@ function focusAttentionReason(
|
||||
void syncVoiceStudioRouteState()
|
||||
}
|
||||
|
||||
function getSuggestedAttentionReasons(
|
||||
completedReason: AttentionReasonFilter,
|
||||
): Exclude<AttentionReasonFilter, 'all'>[] {
|
||||
const orderedReasons: Exclude<AttentionReasonFilter, 'all'>[] = [
|
||||
'pending_confirmation',
|
||||
'safety_intervention',
|
||||
'failed_turn',
|
||||
]
|
||||
return orderedReasons.filter((reason) => reason !== completedReason)
|
||||
}
|
||||
|
||||
async function focusRequestedTarget(sessionId: string) {
|
||||
const target = pendingFocusTarget.value
|
||||
if (!target) return
|
||||
@@ -960,6 +1276,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
clearRecordedAudio()
|
||||
clearAutoAdvanceNotice()
|
||||
clearAttentionCompletionNotice()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1130,14 +1447,17 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<button
|
||||
<div
|
||||
v-for="session in filteredSessions"
|
||||
:key="session.id"
|
||||
type="button"
|
||||
class="w-full rounded-2xl border px-4 py-3 text-left transition-all"
|
||||
class="w-full rounded-2xl border transition-all"
|
||||
:class="activeSession?.id === session.id
|
||||
? 'border-purple-300 bg-purple-50'
|
||||
: 'border-gray-100 bg-white hover:border-gray-300'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-4 pt-3 text-left"
|
||||
@click="loadSessionDetail(session.id)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
@@ -1170,6 +1490,17 @@ onBeforeUnmount(() => {
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{{ formatSessionStatus(session.status) }} · {{ session.total_turns }} 轮
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 rounded-xl border px-3 py-2 text-xs leading-5"
|
||||
:class="getVoiceSessionNextStep(session).toneClass"
|
||||
>
|
||||
<div class="font-medium">
|
||||
下一步:{{ getVoiceSessionNextStep(session).label }}
|
||||
</div>
|
||||
<div class="mt-1 opacity-90">
|
||||
{{ getVoiceSessionNextStep(session).description }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="session.latest_understanding_summary || session.latest_user_transcript || session.latest_safety_message"
|
||||
class="mt-2 line-clamp-2 text-xs leading-5 text-gray-500"
|
||||
@@ -1182,6 +1513,16 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="flex justify-end px-4 pb-3">
|
||||
<BaseButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="openSessionQuickAction(session)"
|
||||
>
|
||||
{{ getVoiceSessionNextAction(session).label }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</div>
|
||||
@@ -1213,6 +1554,44 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard
|
||||
v-if="attentionCompletionNotice"
|
||||
class="border border-emerald-100 bg-emerald-50 text-emerald-800"
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="text-sm leading-6">
|
||||
{{ attentionCompletionNotice.completedReasonLabel }} 已处理完。
|
||||
你可以继续切到下一类 attention,或先回到最近会话总览。
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="reason in getSuggestedAttentionReasons(attentionCompletionNotice.completedReason)"
|
||||
:key="`completion-${reason}`"
|
||||
type="button"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
|
||||
:class="formatAttentionReason(reason).className"
|
||||
@click="focusAttentionReason(reason)"
|
||||
>
|
||||
看{{ formatAttentionReason(reason).label }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-600 transition-colors hover:border-gray-400"
|
||||
@click="setSessionFilter('recent')"
|
||||
>
|
||||
回到最近全部
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-1 text-emerald-500 transition-colors hover:bg-emerald-100 hover:text-emerald-700"
|
||||
@click="clearAttentionCompletionNotice"
|
||||
>
|
||||
<XMarkIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard v-if="voiceAnalytics" class="border border-slate-100 bg-white/90">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -1378,6 +1757,166 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
v-if="storyStateSummary"
|
||||
class="rounded-2xl border border-slate-100 bg-slate-50/80 p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900">当前故事状态</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
这块是把会话里的 `story_state` 翻成家长可读摘要,方便确认“故事现在讲到哪了”。
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-xs font-medium"
|
||||
:class="activeSession.can_finalize
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: hasPendingConfirmation
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: activeSession.can_continue
|
||||
? 'bg-sky-100 text-sky-700'
|
||||
: 'bg-gray-100 text-gray-700'"
|
||||
>
|
||||
{{ activeSession.can_finalize
|
||||
? '当前版本可保存'
|
||||
: hasPendingConfirmation
|
||||
? '需先确认这一轮'
|
||||
: activeSession.can_continue
|
||||
? '可以继续讲下一轮'
|
||||
: '先查看当前状态' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-xl border border-white bg-white px-4 py-3">
|
||||
<div class="text-xs text-gray-500">当前主题</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-800">
|
||||
{{ storyStateSummary.premise || '还在等待第一轮主题' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white bg-white px-4 py-3">
|
||||
<div class="text-xs text-gray-500">最新改写方向</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-800">
|
||||
{{ storyStateSummary.latestDirection || activeSession.latest_user_transcript || '暂未收到新的剧情修正' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white bg-white px-4 py-3">
|
||||
<div class="text-xs text-gray-500">已生成段数</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-800">
|
||||
{{ storyStateSummary.segmentCount }} 段
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white bg-white px-4 py-3">
|
||||
<div class="text-xs text-gray-500">最近意图</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-800">
|
||||
{{ formatIntent(activeSession.latest_detected_intent) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{{ storyStateSummary.coverPromptReady ? '封面提示已准备' : '封面提示会在后续补齐' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="storyStateSummary.latestSegment"
|
||||
class="mt-4 rounded-xl border border-white bg-white px-4 py-3"
|
||||
>
|
||||
<div class="text-xs text-gray-500">最近一段故事</div>
|
||||
<div class="mt-1 text-sm leading-6 text-gray-700">
|
||||
{{ truncateNarrative(storyStateSummary.latestSegment, 140) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="storyActionSteps.length"
|
||||
class="mt-4 rounded-xl border border-white bg-white px-4 py-4"
|
||||
>
|
||||
<div class="text-xs text-gray-500">当前可执行路径</div>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="(step, index) in storyActionSteps"
|
||||
:key="step.key"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<div class="mt-1 flex flex-col items-center">
|
||||
<span
|
||||
class="h-2.5 w-2.5 rounded-full"
|
||||
:class="getStoryActionStepClass(step.status).dot"
|
||||
></span>
|
||||
<span
|
||||
v-if="index < storyActionSteps.length - 1"
|
||||
class="mt-1 h-8 w-px bg-gray-200"
|
||||
></span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-800">{{ step.title }}</span>
|
||||
<span
|
||||
class="rounded-full border px-2.5 py-0.5 text-[11px] font-medium"
|
||||
:class="getStoryActionStepClass(step.status).badge"
|
||||
>
|
||||
{{ step.status === 'done' ? '已完成' : step.status === 'current' ? '当前建议' : '后续步骤' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm leading-6 text-gray-600">
|
||||
{{ step.description }}
|
||||
</div>
|
||||
<div
|
||||
v-if="step.status === 'current' && step.contextValue"
|
||||
class="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-3"
|
||||
>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ step.contextLabel }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm leading-6 text-slate-700">
|
||||
{{ step.contextValue }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="getStoryActionStepButtonLabel(step)"
|
||||
class="mt-3"
|
||||
>
|
||||
<BaseButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@click="handleStoryActionStep(step)"
|
||||
:disabled="
|
||||
sendingTurn
|
||||
|| finalizing
|
||||
|| (step.key === 'finalize' && !activeSession.can_finalize)
|
||||
|| (step.key === 'review_result' && !activeSession.final_story_id)
|
||||
"
|
||||
>
|
||||
{{ getStoryActionStepButtonLabel(step) }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeSession.latest_understanding_summary"
|
||||
class="mt-3 rounded-xl border border-dashed border-slate-200 bg-white px-4 py-3"
|
||||
>
|
||||
<div class="text-xs text-gray-500">当前系统理解</div>
|
||||
<div class="mt-1 text-sm leading-6 text-slate-700">
|
||||
{{ activeSession.latest_understanding_summary }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="storyStateSummary.safetyFlags.length"
|
||||
class="mt-3 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-rose-700"
|
||||
>
|
||||
<div class="text-xs font-medium">当前故事仍带有安全标记</div>
|
||||
<div class="mt-1 text-sm">
|
||||
{{ storyStateSummary.safetyFlags.join(' / ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeSession.latest_requires_confirmation && latestPendingConfirmationTurn"
|
||||
id="voice-attention-confirmation-card"
|
||||
@@ -1500,7 +2039,7 @@ onBeforeUnmount(() => {
|
||||
<div class="mt-3">
|
||||
<BaseButton size="sm" variant="secondary" @click="viewFinalStory">
|
||||
<BookOpenIcon class="h-4 w-4" />
|
||||
打开正式故事
|
||||
查看正式故事
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1735,7 +2274,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
|
||||
<div class="rounded-2xl border border-gray-100 bg-white p-4">
|
||||
<h3 class="font-semibold text-gray-900">故事状态快照</h3>
|
||||
<h3 class="font-semibold text-gray-900">原始状态快照(调试)</h3>
|
||||
<pre class="mt-4 overflow-x-auto rounded-xl bg-gray-950 p-4 text-xs leading-6 text-emerald-200">{{ JSON.stringify(activeSession.story_state, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user