feat: polish voice studio workflow and bilingual copy

This commit is contained in:
2026-04-21 18:05:22 +08:00
parent 9f74a93274
commit 4db04e61e9
12 changed files with 820 additions and 150 deletions

View File

@@ -102,7 +102,7 @@ const generationSteps = computed(() => {
'Worker 会生成故事正文并保存主记录...', 'Worker 会生成故事正文并保存主记录...',
'主内容一可读就会自动跳转详情页...', '主内容一可读就会自动跳转详情页...',
'封面会继续在后台补全,失败也能重试...', '封面会继续在后台补全,失败也能重试...',
'马上进入故事详情页。', '稍后会自动进入故事详情页。',
] ]
}) })

View File

@@ -3,14 +3,14 @@
"title": "DreamWeaver", "title": "DreamWeaver",
"navHome": "Home", "navHome": "Home",
"navMyStories": "My Stories", "navMyStories": "My Stories",
"navProfiles": "Profiles", "navProfiles": "Child Profiles",
"navUniverses": "Universes", "navUniverses": "Story Universe",
"navAdmin": "Providers Admin" "navAdmin": "Provider Management"
}, },
"home": { "home": {
"heroTitle": "Weave magical", "heroTitle": "Weave magical",
"heroTitleHighlight": "bedtime stories for your child", "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", "heroCta": "Start Creating",
"heroCtaSecondary": "Learn More", "heroCtaSecondary": "Learn More",
"heroPreviewTitle": "Bunny's Brave Adventure", "heroPreviewTitle": "Bunny's Brave Adventure",
@@ -25,15 +25,15 @@
"feature1Title": "AI-Powered Creation", "feature1Title": "AI-Powered Creation",
"feature1Desc": "Enter a few keywords, and AI instantly creates an imaginative original story for your child", "feature1Desc": "Enter a few keywords, and AI instantly creates an imaginative original story for your child",
"feature2Title": "Personalized Memory", "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", "feature3Title": "Beautiful AI Illustrations",
"feature3Desc": "Automatically generate unique cover illustrations for each story, bringing them to life", "feature3Desc": "Automatically generate unique cover illustrations for each story, bringing them to life",
"feature4Title": "Warm Voice Narration", "feature4Title": "Warm Voice Narration",
"feature4Desc": "Professional AI narration with a warm voice to accompany your child into sweet dreams", "feature4Desc": "Professional AI narration with a warm voice to accompany your child into sweet dreams",
"feature5Title": "Educational Themes", "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", "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", "howItWorksTitle": "How It Works",
"howItWorksSubtitle": "Four steps to start your magical story journey", "howItWorksSubtitle": "Four steps to start your magical story journey",
@@ -67,30 +67,30 @@
"faqTitle": "Frequently Asked Questions", "faqTitle": "Frequently Asked Questions",
"faq1Question": "What age is DreamWeaver suitable for?", "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?", "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?", "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.", "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?", "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.", "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?", "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?", "ctaTitle": "Ready to Create Something Magical for Your Child?",
"ctaSubtitle": "Start now and let AI weave unique stories for your child's growth", "ctaSubtitle": "Start now and let AI weave a one-of-a-kind story for your child's growth",
"ctaButton": "Start Creating Free", "ctaButton": "Start Creating for Free",
"ctaNote": "No credit card required", "ctaNote": "No credit card required",
"createModalTitle": "Create New Story", "createModalTitle": "Create New Story",
"inputTypeKeywords": "Keywords", "inputTypeKeywords": "Create from Keywords",
"inputTypeStory": "Polish Story", "inputTypeStory": "Refine a Story",
"selectProfile": "Select Child Profile", "selectProfile": "Select Child Profile",
"selectProfileOptional": "(Optional)", "selectProfileOptional": "(Optional)",
"selectUniverse": "Select Story Universe", "selectUniverse": "Select Story Universe",
"noProfile": "No profile", "noProfile": "No profile",
"noUniverse": "No universe", "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", "inputLabel": "Enter Keywords",
"inputLabelStory": "Enter Your Story", "inputLabelStory": "Enter Your Story",
"inputPlaceholder": "e.g., bunny, forest, courage, friendship...", "inputPlaceholder": "e.g., bunny, forest, courage, friendship...",
@@ -107,14 +107,14 @@
"errorEmpty": "Please enter content", "errorEmpty": "Please enter content",
"errorLogin": "Please log in first", "errorLogin": "Please log in first",
"generating": "Weaving your story...", "generating": "Weaving your story...",
"loginFirst": "Please Login", "loginFirst": "Please log in",
"startCreate": "Create Magic Story" "startCreate": "Create Story"
}, },
"stories": { "stories": {
"myStories": "My Stories", "myStories": "My Stories",
"view": "View", "view": "View",
"delete": "Delete", "delete": "Delete",
"confirmDelete": "Are you sure to delete this story?", "confirmDelete": "Are you sure you want to delete this story?",
"noStories": "No stories yet." "noStories": "No stories yet."
}, },
"storyDetail": { "storyDetail": {
@@ -122,7 +122,7 @@
"generateImage": "Generate Cover", "generateImage": "Generate Cover",
"playAudio": "Play Audio", "playAudio": "Play Audio",
"modeGenerated": "Generated", "modeGenerated": "Generated",
"modeEnhanced": "Enhanced" "modeEnhanced": "Refined"
}, },
"admin": { "admin": {
"title": "Provider Management", "title": "Provider Management",

View File

@@ -33,7 +33,7 @@
"feature5Title": "教育主题融入", "feature5Title": "教育主题融入",
"feature5Desc": "勇气、友谊、分享、诚实...在故事中自然传递正向价值观", "feature5Desc": "勇气、友谊、分享、诚实...在故事中自然传递正向价值观",
"feature6Title": "故事宇宙", "feature6Title": "故事宇宙",
"feature6Desc": "创建专属世界观,让喜爱的角色在不同故事中持续冒险", "feature6Desc": "创建专属故事宇宙,让喜爱的角色在不同故事中持续冒险",
"howItWorksTitle": "如何使用", "howItWorksTitle": "如何使用",
"howItWorksSubtitle": "四步开启奇妙故事之旅", "howItWorksSubtitle": "四步开启奇妙故事之旅",
@@ -69,7 +69,7 @@
"faq1Question": "梦语织机适合多大的孩子?", "faq1Question": "梦语织机适合多大的孩子?",
"faq1Answer": "我们专为 3-8 岁儿童设计,故事内容、语言难度和教育主题都针对这个年龄段优化。", "faq1Answer": "我们专为 3-8 岁儿童设计,故事内容、语言难度和教育主题都针对这个年龄段优化。",
"faq2Question": "生成的故事安全吗?", "faq2Question": "生成的故事安全吗?",
"faq2Answer": "绝对安全。所有故事都经过内容过滤,确保适合儿童阅读,传递积极正向的价值观。", "faq2Answer": "所有生成内容都会经过安全过滤,以更好地确保适合儿童阅读,传递积极正向的价值观。",
"faq3Question": "可以自定义故事角色吗?", "faq3Question": "可以自定义故事角色吗?",
"faq3Answer": "可以您可以在孩子档案中设置喜好或在创作时指定角色名称、特点AI 会将其融入故事。", "faq3Answer": "可以您可以在孩子档案中设置喜好或在创作时指定角色名称、特点AI 会将其融入故事。",
"faq4Question": "故事会重复吗?", "faq4Question": "故事会重复吗?",
@@ -77,7 +77,7 @@
"faq5Question": "支持哪些语言?", "faq5Question": "支持哪些语言?",
"faq5Answer": "目前支持中文和英文,您可以随时切换界面语言,故事也会相应调整。", "faq5Answer": "目前支持中文和英文,您可以随时切换界面语言,故事也会相应调整。",
"ctaTitle": "准备好为孩子创造魔法了吗?", "ctaTitle": "准备好为孩子创作奇妙故事了吗?",
"ctaSubtitle": "立即开始,让 AI 为您的孩子编织独一无二的成长故事", "ctaSubtitle": "立即开始,让 AI 为您的孩子编织独一无二的成长故事",
"ctaButton": "免费开始创作", "ctaButton": "免费开始创作",
"ctaNote": "无需信用卡,立即体验", "ctaNote": "无需信用卡,立即体验",
@@ -93,7 +93,7 @@
"noUniverseHint": "当前档案暂无宇宙,可在「故事宇宙」中创建", "noUniverseHint": "当前档案暂无宇宙,可在「故事宇宙」中创建",
"inputLabel": "输入关键词", "inputLabel": "输入关键词",
"inputLabelStory": "输入您的故事", "inputLabelStory": "输入您的故事",
"inputPlaceholder": "例如:小兔子, 森林, 勇气, 友谊...", "inputPlaceholder": "例如:小兔子、森林、勇气、友谊……",
"inputPlaceholderStory": "在这里输入您想要润色的故事...", "inputPlaceholderStory": "在这里输入您想要润色的故事...",
"themeLabel": "选择教育主题", "themeLabel": "选择教育主题",
"themeOptional": "(可选)", "themeOptional": "(可选)",
@@ -108,14 +108,14 @@
"errorLogin": "请先登录", "errorLogin": "请先登录",
"generating": "正在编织故事...", "generating": "正在编织故事...",
"loginFirst": "请先登录", "loginFirst": "请先登录",
"startCreate": "开始创作魔法故事" "startCreate": "开始创作"
}, },
"stories": { "stories": {
"myStories": "我的故事", "myStories": "我的故事",
"view": "查看", "view": "查看",
"delete": "删除", "delete": "删除",
"confirmDelete": "确定删除这个故事吗?", "confirmDelete": "确定删除这个故事吗?",
"noStories": "暂无故事。" "noStories": "还没有故事。"
}, },
"storyDetail": { "storyDetail": {
"back": "返回", "back": "返回",
@@ -136,12 +136,12 @@
"type": "类型", "type": "类型",
"adapter": "适配器", "adapter": "适配器",
"model": "模型", "model": "模型",
"apiBase": "API Base", "apiBase": "API 地址",
"timeout": "超时 (ms)", "timeout": "超时ms",
"retries": "最大重试", "retries": "最大重试",
"weight": "权重", "weight": "权重",
"priority": "优先级", "priority": "优先级",
"configRef": "Config Ref", "configRef": "配置引用",
"enabled": "启用", "enabled": "启用",
"actions": "操作" "actions": "操作"
}, },

View File

@@ -288,7 +288,7 @@
:key="p" :key="p"
@click="cloneDefault(type, 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" 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 }} {{ p }}
</button> </button>

View File

@@ -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"> <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" /> <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> </div>
<!-- 时间轴内容 --> <!-- 时间轴内容 -->

View File

@@ -102,7 +102,7 @@ const generationSteps = computed(() => {
'Worker 会生成故事正文并保存主记录...', 'Worker 会生成故事正文并保存主记录...',
'主内容一可读就会自动跳转详情页...', '主内容一可读就会自动跳转详情页...',
'封面会继续在后台补全,失败也能重试...', '封面会继续在后台补全,失败也能重试...',
'马上进入故事详情页。', '稍后会自动进入故事详情页。',
] ]
}) })

View File

@@ -3,14 +3,14 @@
"title": "DreamWeaver", "title": "DreamWeaver",
"navHome": "Home", "navHome": "Home",
"navMyStories": "My Stories", "navMyStories": "My Stories",
"navProfiles": "Profiles", "navProfiles": "Child Profiles",
"navUniverses": "Universes", "navUniverses": "Story Universe",
"navAdmin": "Providers Admin" "navAdmin": "Provider Management"
}, },
"home": { "home": {
"heroTitle": "Weave magical", "heroTitle": "Weave magical",
"heroTitleHighlight": "bedtime stories for your child", "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", "heroCta": "Start Creating",
"heroCtaSecondary": "Learn More", "heroCtaSecondary": "Learn More",
"heroPreviewTitle": "Bunny's Brave Adventure", "heroPreviewTitle": "Bunny's Brave Adventure",
@@ -25,15 +25,15 @@
"feature1Title": "AI-Powered Creation", "feature1Title": "AI-Powered Creation",
"feature1Desc": "Enter a few keywords, and AI instantly creates an imaginative original story for your child", "feature1Desc": "Enter a few keywords, and AI instantly creates an imaginative original story for your child",
"feature2Title": "Personalized Memory", "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", "feature3Title": "Beautiful AI Illustrations",
"feature3Desc": "Automatically generate unique cover illustrations for each story, bringing them to life", "feature3Desc": "Automatically generate unique cover illustrations for each story, bringing them to life",
"feature4Title": "Warm Voice Narration", "feature4Title": "Warm Voice Narration",
"feature4Desc": "Professional AI narration with a warm voice to accompany your child into sweet dreams", "feature4Desc": "Professional AI narration with a warm voice to accompany your child into sweet dreams",
"feature5Title": "Educational Themes", "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", "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", "howItWorksTitle": "How It Works",
"howItWorksSubtitle": "Four steps to start your magical story journey", "howItWorksSubtitle": "Four steps to start your magical story journey",
@@ -67,30 +67,30 @@
"faqTitle": "Frequently Asked Questions", "faqTitle": "Frequently Asked Questions",
"faq1Question": "What age is DreamWeaver suitable for?", "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?", "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?", "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.", "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?", "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.", "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?", "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?", "ctaTitle": "Ready to Create Something Magical for Your Child?",
"ctaSubtitle": "Start now and let AI weave unique stories for your child's growth", "ctaSubtitle": "Start now and let AI weave a one-of-a-kind story for your child's growth",
"ctaButton": "Start Creating Free", "ctaButton": "Start Creating for Free",
"ctaNote": "No credit card required", "ctaNote": "No credit card required",
"createModalTitle": "Create New Story", "createModalTitle": "Create New Story",
"inputTypeKeywords": "Keywords", "inputTypeKeywords": "Create from Keywords",
"inputTypeStory": "Polish Story", "inputTypeStory": "Refine a Story",
"selectProfile": "Select Child Profile", "selectProfile": "Select Child Profile",
"selectProfileOptional": "(Optional)", "selectProfileOptional": "(Optional)",
"selectUniverse": "Select Story Universe", "selectUniverse": "Select Story Universe",
"noProfile": "No profile", "noProfile": "No profile",
"noUniverse": "No universe", "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", "inputLabel": "Enter Keywords",
"inputLabelStory": "Enter Your Story", "inputLabelStory": "Enter Your Story",
"inputPlaceholder": "e.g., bunny, forest, courage, friendship...", "inputPlaceholder": "e.g., bunny, forest, courage, friendship...",
@@ -107,14 +107,14 @@
"errorEmpty": "Please enter content", "errorEmpty": "Please enter content",
"errorLogin": "Please log in first", "errorLogin": "Please log in first",
"generating": "Weaving your story...", "generating": "Weaving your story...",
"loginFirst": "Please Login", "loginFirst": "Please log in",
"startCreate": "Create Magic Story" "startCreate": "Create Story"
}, },
"stories": { "stories": {
"myStories": "My Stories", "myStories": "My Stories",
"view": "View", "view": "View",
"delete": "Delete", "delete": "Delete",
"confirmDelete": "Are you sure to delete this story?", "confirmDelete": "Are you sure you want to delete this story?",
"noStories": "No stories yet." "noStories": "No stories yet."
}, },
"storyDetail": { "storyDetail": {
@@ -122,7 +122,7 @@
"generateImage": "Generate Cover", "generateImage": "Generate Cover",
"playAudio": "Play Audio", "playAudio": "Play Audio",
"modeGenerated": "Generated", "modeGenerated": "Generated",
"modeEnhanced": "Enhanced" "modeEnhanced": "Refined"
}, },
"admin": { "admin": {
"title": "Provider Management", "title": "Provider Management",

View File

@@ -33,7 +33,7 @@
"feature5Title": "教育主题融入", "feature5Title": "教育主题融入",
"feature5Desc": "勇气、友谊、分享、诚实...在故事中自然传递正向价值观", "feature5Desc": "勇气、友谊、分享、诚实...在故事中自然传递正向价值观",
"feature6Title": "故事宇宙", "feature6Title": "故事宇宙",
"feature6Desc": "创建专属世界观,让喜爱的角色在不同故事中持续冒险", "feature6Desc": "创建专属故事宇宙,让喜爱的角色在不同故事中持续冒险",
"howItWorksTitle": "如何使用", "howItWorksTitle": "如何使用",
"howItWorksSubtitle": "四步开启奇妙故事之旅", "howItWorksSubtitle": "四步开启奇妙故事之旅",
@@ -69,7 +69,7 @@
"faq1Question": "梦语织机适合多大的孩子?", "faq1Question": "梦语织机适合多大的孩子?",
"faq1Answer": "我们专为 3-8 岁儿童设计,故事内容、语言难度和教育主题都针对这个年龄段优化。", "faq1Answer": "我们专为 3-8 岁儿童设计,故事内容、语言难度和教育主题都针对这个年龄段优化。",
"faq2Question": "生成的故事安全吗?", "faq2Question": "生成的故事安全吗?",
"faq2Answer": "绝对安全。所有故事都经过内容过滤,确保适合儿童阅读,传递积极正向的价值观。", "faq2Answer": "所有生成内容都会经过安全过滤,以更好地确保适合儿童阅读,传递积极正向的价值观。",
"faq3Question": "可以自定义故事角色吗?", "faq3Question": "可以自定义故事角色吗?",
"faq3Answer": "可以您可以在孩子档案中设置喜好或在创作时指定角色名称、特点AI 会将其融入故事。", "faq3Answer": "可以您可以在孩子档案中设置喜好或在创作时指定角色名称、特点AI 会将其融入故事。",
"faq4Question": "故事会重复吗?", "faq4Question": "故事会重复吗?",
@@ -77,7 +77,7 @@
"faq5Question": "支持哪些语言?", "faq5Question": "支持哪些语言?",
"faq5Answer": "目前支持中文和英文,您可以随时切换界面语言,故事也会相应调整。", "faq5Answer": "目前支持中文和英文,您可以随时切换界面语言,故事也会相应调整。",
"ctaTitle": "准备好为孩子创造魔法了吗?", "ctaTitle": "准备好为孩子创作奇妙故事了吗?",
"ctaSubtitle": "立即开始,让 AI 为您的孩子编织独一无二的成长故事", "ctaSubtitle": "立即开始,让 AI 为您的孩子编织独一无二的成长故事",
"ctaButton": "免费开始创作", "ctaButton": "免费开始创作",
"ctaNote": "无需信用卡,立即体验", "ctaNote": "无需信用卡,立即体验",
@@ -93,7 +93,7 @@
"noUniverseHint": "当前档案暂无宇宙,可在「故事宇宙」中创建", "noUniverseHint": "当前档案暂无宇宙,可在「故事宇宙」中创建",
"inputLabel": "输入关键词", "inputLabel": "输入关键词",
"inputLabelStory": "输入您的故事", "inputLabelStory": "输入您的故事",
"inputPlaceholder": "例如:小兔子, 森林, 勇气, 友谊...", "inputPlaceholder": "例如:小兔子、森林、勇气、友谊……",
"inputPlaceholderStory": "在这里输入您想要润色的故事...", "inputPlaceholderStory": "在这里输入您想要润色的故事...",
"themeLabel": "选择教育主题", "themeLabel": "选择教育主题",
"themeOptional": "(可选)", "themeOptional": "(可选)",
@@ -108,14 +108,14 @@
"errorLogin": "请先登录", "errorLogin": "请先登录",
"generating": "正在编织故事...", "generating": "正在编织故事...",
"loginFirst": "请先登录", "loginFirst": "请先登录",
"startCreate": "开始创作魔法故事" "startCreate": "开始创作"
}, },
"stories": { "stories": {
"myStories": "我的故事", "myStories": "我的故事",
"view": "查看", "view": "查看",
"delete": "删除", "delete": "删除",
"confirmDelete": "确定删除这个故事吗?", "confirmDelete": "确定删除这个故事吗?",
"noStories": "暂无故事。" "noStories": "还没有故事。"
}, },
"storyDetail": { "storyDetail": {
"back": "返回", "back": "返回",
@@ -136,12 +136,12 @@
"type": "类型", "type": "类型",
"adapter": "适配器", "adapter": "适配器",
"model": "模型", "model": "模型",
"apiBase": "API Base", "apiBase": "API 地址",
"timeout": "超时 (ms)", "timeout": "超时ms",
"retries": "最大重试", "retries": "最大重试",
"weight": "权重", "weight": "权重",
"priority": "优先级", "priority": "优先级",
"configRef": "Config Ref", "configRef": "配置引用",
"enabled": "启用", "enabled": "启用",
"actions": "操作" "actions": "操作"
}, },

View 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: '查看详情',
}
}

View File

@@ -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"> <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" /> <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> </div>
<!-- 时间轴内容 --> <!-- 时间轴内容 -->

View File

@@ -9,6 +9,10 @@ import EmptyState from '../components/ui/EmptyState.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue' import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import type { GenerationOpsSummary, GenerationProviderAnalytics } from '../types/generation' import type { GenerationOpsSummary, GenerationProviderAnalytics } from '../types/generation'
import type { VoiceSessionAnalytics, VoiceSessionSummary } from '../types/voiceSession' import type { VoiceSessionAnalytics, VoiceSessionSummary } from '../types/voiceSession'
import {
getVoiceSessionNextAction,
getVoiceSessionNextStep,
} from '../utils/voiceSession'
import { import {
getAssetStatusMeta, getAssetStatusMeta,
getGenerationStatusMeta, getGenerationStatusMeta,
@@ -172,31 +176,16 @@ function continueActiveVoiceSession() {
goToVoiceStudio() goToVoiceStudio()
return return
} }
if (activeVoiceSession.value.latest_requires_confirmation) { const action = getVoiceSessionNextAction(activeVoiceSession.value)
goToVoiceStudio({ if (action.storyId) {
reason: 'pending_confirmation', router.push(`/story/${action.storyId}`)
sessionId: activeVoiceSession.value.id,
focus: 'confirmation',
})
return return
} }
if (activeVoiceSession.value.latest_safety_message) {
goToVoiceStudio({ goToVoiceStudio({
reason: 'safety_intervention', reason: action.reason,
sessionId: activeVoiceSession.value.id, 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) { function getStoryLink(story: StoryItem) {
@@ -316,10 +305,21 @@ watch([selectedWindow, selectedCapability, selectedVoiceWindow], () => {
> >
最近一轮触发了儿童内容安全兜底建议回到工作台查看详细记录 最近一轮触发了儿童内容安全兜底建议回到工作台查看详细记录
</p> </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> </div>
<BaseButton @click="continueActiveVoiceSession"> <BaseButton @click="continueActiveVoiceSession">
<SparklesIcon class="h-5 w-5 mr-2" /> <SparklesIcon class="h-5 w-5 mr-2" />
继续语音共创 {{ getVoiceSessionNextAction(activeVoiceSession).label }}
</BaseButton> </BaseButton>
</div> </div>
</BaseCard> </BaseCard>

View File

@@ -19,6 +19,10 @@ import BaseTextarea from '../components/ui/BaseTextarea.vue'
import GenerationTrace from '../components/GenerationTrace.vue' import GenerationTrace from '../components/GenerationTrace.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue' import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import EmptyState from '../components/ui/EmptyState.vue' import EmptyState from '../components/ui/EmptyState.vue'
import {
getVoiceSessionNextAction,
getVoiceSessionNextStep,
} from '../utils/voiceSession'
import { import {
ArrowPathIcon, ArrowPathIcon,
BookOpenIcon, BookOpenIcon,
@@ -40,6 +44,24 @@ interface StoryUniverse {
name: string 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 SessionFilter = 'active' | 'attention' | 'recent'
type AttentionReasonFilter = 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn' type AttentionReasonFilter = 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn'
type VoiceStudioFocusTarget = 'confirmation' | 'safety' | 'failed' | 'text' type VoiceStudioFocusTarget = 'confirmation' | 'safety' | 'failed' | 'text'
@@ -80,6 +102,7 @@ let recordingTimer: number | null = null
let recordingStartedAt = 0 let recordingStartedAt = 0
let sessionPollTimer: number | null = null let sessionPollTimer: number | null = null
let autoAdvanceNoticeTimer: number | null = null let autoAdvanceNoticeTimer: number | null = null
let attentionCompletionNoticeTimer: number | null = null
const recordedBlob = ref<Blob | null>(null) const recordedBlob = ref<Blob | null>(null)
const recordedAudioUrl = ref<string | null>(null) const recordedAudioUrl = ref<string | null>(null)
@@ -91,21 +114,7 @@ const universeOptions = computed(() =>
universes.value.map((universe) => ({ value: universe.id, label: universe.name })), universes.value.map((universe) => ({ value: universe.id, label: universe.name })),
) )
const filteredSessions = computed(() => { const filteredSessions = computed(() => {
switch (sessionFilter.value) { return resolveDisplayedSessions(sessions.value)
case 'active':
return sessions.value.filter((session) => session.can_continue)
case 'attention':
return sessions.value.filter(
(session) =>
sessionNeedsAttention(session)
&& (
attentionReasonFilter.value === 'all'
|| session.attention_reasons.includes(attentionReasonFilter.value)
),
)
default:
return sessions.value
}
}) })
const activeTurnList = computed(() => activeSession.value?.recent_turns ?? []) const activeTurnList = computed(() => activeSession.value?.recent_turns ?? [])
@@ -129,6 +138,127 @@ const finalStorySummary = computed(() => {
const value = activeSession.value?.story_state?.final_summary const value = activeSession.value?.story_state?.final_summary
return typeof value === 'string' ? value : null return typeof value === 'string' ? value : null
}) })
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 finalStoryId = computed(() => activeSession.value?.final_story_id ?? null)
const finalStoryHasAssetWork = computed(() => Boolean(finalStoryId.value)) const finalStoryHasAssetWork = computed(() => Boolean(finalStoryId.value))
const turnSuccessRateLabel = computed(() => { const turnSuccessRateLabel = computed(() => {
@@ -166,6 +296,10 @@ const autoAdvanceNotice = ref<{
toTitle: string toTitle: string
reasonLabel: string reasonLabel: string
} | null>(null) } | null>(null)
const attentionCompletionNotice = ref<{
completedReason: AttentionReasonFilter
completedReasonLabel: string
} | null>(null)
function formatSessionStatus(status: string) { function formatSessionStatus(status: string) {
switch (status) { switch (status) {
@@ -238,6 +372,82 @@ function formatConfidence(value: number | null | undefined) {
return `${Math.round(value * 100)}%` 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) { function formatAnalyticsWindowLabel(windowDays: number | null | undefined) {
if (typeof windowDays === 'number') { if (typeof windowDays === 'number') {
return `最近 ${windowDays}` 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 { function parseSessionFilter(value: unknown): SessionFilter | null {
if (value === 'active' || value === 'attention' || value === 'recent') { if (value === 'active' || value === 'attention' || value === 'recent') {
return value return value
@@ -302,6 +565,7 @@ function parseFocusTarget(value: unknown): VoiceStudioFocusTarget | null {
function applyRouteState() { function applyRouteState() {
suppressAutoAdvanceNotice.value = true suppressAutoAdvanceNotice.value = true
clearAttentionCompletionNotice()
sessionFilter.value = parseSessionFilter(route.query.filter) ?? 'active' sessionFilter.value = parseSessionFilter(route.query.filter) ?? 'active'
attentionReasonFilter.value = parseAttentionReasonFilter(route.query.reason) ?? 'all' attentionReasonFilter.value = parseAttentionReasonFilter(route.query.reason) ?? 'all'
pendingFocusTarget.value = parseFocusTarget(route.query.focus) pendingFocusTarget.value = parseFocusTarget(route.query.focus)
@@ -414,6 +678,14 @@ function clearAutoAdvanceNotice() {
autoAdvanceNotice.value = null autoAdvanceNotice.value = null
} }
function clearAttentionCompletionNotice() {
if (attentionCompletionNoticeTimer) {
window.clearTimeout(attentionCompletionNoticeTimer)
attentionCompletionNoticeTimer = null
}
attentionCompletionNotice.value = null
}
function showAutoAdvanceNotice(fromTitle: string, toTitle: string) { function showAutoAdvanceNotice(fromTitle: string, toTitle: string) {
clearAutoAdvanceNotice() clearAutoAdvanceNotice()
autoAdvanceNotice.value = { autoAdvanceNotice.value = {
@@ -430,6 +702,18 @@ function showAutoAdvanceNotice(fromTitle: string, toTitle: string) {
}, 5000) }, 5000)
} }
function showAttentionCompletionNotice(completedReason: AttentionReasonFilter) {
clearAttentionCompletionNotice()
attentionCompletionNotice.value = {
completedReason,
completedReasonLabel: formatAttentionReasonTitle(completedReason),
}
attentionCompletionNoticeTimer = window.setTimeout(() => {
attentionCompletionNotice.value = null
attentionCompletionNoticeTimer = null
}, 6000)
}
async function fetchProfiles() { async function fetchProfiles() {
if (!userStore.user) return if (!userStore.user) return
const data = await api.get<{ profiles: ChildProfile[] }>('/api/profiles') const data = await api.get<{ profiles: ChildProfile[] }>('/api/profiles')
@@ -458,20 +742,21 @@ async function loadSessions() {
try { try {
const previousActiveSession = activeSession.value const previousActiveSession = activeSession.value
sessions.value = await api.get<VoiceSessionSummary[]>(buildVoiceSessionListPath()) sessions.value = await api.get<VoiceSessionSummary[]>(buildVoiceSessionListPath())
const displayedSessions = resolveDisplayedSessions(sessions.value)
if ( if (
(requestedSessionId.value || pendingFocusTarget.value) (requestedSessionId.value || pendingFocusTarget.value)
&& requestedSessionId.value && requestedSessionId.value
&& !sessions.value.some((item) => item.id === requestedSessionId.value) && !displayedSessions.some((item) => item.id === requestedSessionId.value)
) { ) {
void syncVoiceStudioRouteState() void syncVoiceStudioRouteState()
} }
const preferredSession = ( const preferredSession = (
requestedSessionId.value requestedSessionId.value
? sessions.value.find((item) => item.id === requestedSessionId.value) ? displayedSessions.find((item) => item.id === requestedSessionId.value)
: null : null
) ?? sessions.value.find((item) => item.can_continue) ?? sessions.value[0] ) ?? displayedSessions.find((item) => item.can_continue) ?? displayedSessions[0]
const currentSessionStillVisible = activeSession.value const currentSessionStillVisible = activeSession.value
? sessions.value.some((item) => item.id === activeSession.value?.id) ? displayedSessions.some((item) => item.id === activeSession.value?.id)
: false : false
if ( if (
!activeSession.value !activeSession.value
@@ -497,6 +782,14 @@ async function loadSessions() {
} }
await loadSessionDetail(preferredSession.id) await loadSessionDetail(preferredSession.id)
} else if (sessionFilter.value !== 'recent') { } else if (sessionFilter.value !== 'recent') {
if (
sessionFilter.value === 'attention'
&& previousActiveSession
&& !currentSessionStillVisible
&& !suppressAutoAdvanceNotice.value
) {
showAttentionCompletionNotice(attentionReasonFilter.value)
}
activeSession.value = null activeSession.value = null
} }
} }
@@ -812,12 +1105,23 @@ function viewFinalStory() {
router.push(`/story/${activeSession.value.final_story_id}`) 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') { function setAnalyticsWindow(value: '7' | '30' | 'all') {
analyticsWindow.value = value analyticsWindow.value = value
} }
function setSessionFilter(value: SessionFilter) { function setSessionFilter(value: SessionFilter) {
suppressAutoAdvanceNotice.value = true suppressAutoAdvanceNotice.value = true
clearAttentionCompletionNotice()
sessionFilter.value = value sessionFilter.value = value
if (value !== 'attention') { if (value !== 'attention') {
attentionReasonFilter.value = 'all' attentionReasonFilter.value = 'all'
@@ -840,6 +1144,7 @@ function setAttentionReasonFilter(
value: 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn', value: 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn',
) { ) {
suppressAutoAdvanceNotice.value = true suppressAutoAdvanceNotice.value = true
clearAttentionCompletionNotice()
attentionReasonFilter.value = value attentionReasonFilter.value = value
void syncVoiceStudioRouteState() void syncVoiceStudioRouteState()
} }
@@ -852,6 +1157,17 @@ function focusAttentionReason(
void syncVoiceStudioRouteState() 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) { async function focusRequestedTarget(sessionId: string) {
const target = pendingFocusTarget.value const target = pendingFocusTarget.value
if (!target) return if (!target) return
@@ -960,6 +1276,7 @@ onBeforeUnmount(() => {
} }
clearRecordedAudio() clearRecordedAudio()
clearAutoAdvanceNotice() clearAutoAdvanceNotice()
clearAttentionCompletionNotice()
}) })
</script> </script>
@@ -1130,14 +1447,17 @@ onBeforeUnmount(() => {
</div> </div>
<div v-else class="mt-4 space-y-3"> <div v-else class="mt-4 space-y-3">
<button <div
v-for="session in filteredSessions" v-for="session in filteredSessions"
:key="session.id" :key="session.id"
type="button" class="w-full rounded-2xl border transition-all"
class="w-full rounded-2xl border px-4 py-3 text-left transition-all"
:class="activeSession?.id === session.id :class="activeSession?.id === session.id
? 'border-purple-300 bg-purple-50' ? 'border-purple-300 bg-purple-50'
: 'border-gray-100 bg-white hover:border-gray-300'" : '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)" @click="loadSessionDetail(session.id)"
> >
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
@@ -1170,6 +1490,17 @@ onBeforeUnmount(() => {
<div class="mt-1 text-xs text-gray-500"> <div class="mt-1 text-xs text-gray-500">
{{ formatSessionStatus(session.status) }} · {{ session.total_turns }} {{ formatSessionStatus(session.status) }} · {{ session.total_turns }}
</div> </div>
<div
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 <div
v-if="session.latest_understanding_summary || session.latest_user_transcript || session.latest_safety_message" 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" class="mt-2 line-clamp-2 text-xs leading-5 text-gray-500"
@@ -1182,6 +1513,16 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
</button> </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> </div>
</BaseCard> </BaseCard>
</div> </div>
@@ -1213,6 +1554,44 @@ onBeforeUnmount(() => {
</div> </div>
</BaseCard> </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"> <BaseCard v-if="voiceAnalytics" class="border border-slate-100 bg-white/90">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -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="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div class="space-y-6"> <div class="space-y-6">
<div
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 <div
v-if="activeSession.latest_requires_confirmation && latestPendingConfirmationTurn" v-if="activeSession.latest_requires_confirmation && latestPendingConfirmationTurn"
id="voice-attention-confirmation-card" id="voice-attention-confirmation-card"
@@ -1500,7 +2039,7 @@ onBeforeUnmount(() => {
<div class="mt-3"> <div class="mt-3">
<BaseButton size="sm" variant="secondary" @click="viewFinalStory"> <BaseButton size="sm" variant="secondary" @click="viewFinalStory">
<BookOpenIcon class="h-4 w-4" /> <BookOpenIcon class="h-4 w-4" />
打开正式故事 查看正式故事
</BaseButton> </BaseButton>
</div> </div>
</div> </div>
@@ -1735,7 +2274,7 @@ onBeforeUnmount(() => {
/> />
<div class="rounded-2xl border border-gray-100 bg-white p-4"> <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> <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> </div>