From 4db04e61e984088283c285c9a233149d8f125e14 Mon Sep 17 00:00:00 2001 From: torin Date: Tue, 21 Apr 2026 18:05:22 +0800 Subject: [PATCH] feat: polish voice studio workflow and bilingual copy --- .../src/components/CreateStoryModal.vue | 2 +- admin-frontend/src/locales/en.json | 42 +- admin-frontend/src/locales/zh.json | 18 +- admin-frontend/src/views/AdminProviders.vue | 2 +- .../src/views/ChildProfileTimeline.vue | 2 +- frontend/src/components/CreateStoryModal.vue | 2 +- frontend/src/locales/en.json | 42 +- frontend/src/locales/zh.json | 18 +- frontend/src/utils/voiceSession.ts | 131 ++++ frontend/src/views/ChildProfileTimeline.vue | 2 +- frontend/src/views/MyStories.vue | 48 +- frontend/src/views/VoiceStudio.vue | 661 ++++++++++++++++-- 12 files changed, 820 insertions(+), 150 deletions(-) create mode 100644 frontend/src/utils/voiceSession.ts diff --git a/admin-frontend/src/components/CreateStoryModal.vue b/admin-frontend/src/components/CreateStoryModal.vue index 5110a81..5c37d51 100644 --- a/admin-frontend/src/components/CreateStoryModal.vue +++ b/admin-frontend/src/components/CreateStoryModal.vue @@ -102,7 +102,7 @@ const generationSteps = computed(() => { 'Worker 会生成故事正文并保存主记录...', '主内容一可读就会自动跳转详情页...', '封面会继续在后台补全,失败也能重试...', - '马上进入故事详情页。', + '稍后会自动进入故事详情页。', ] }) diff --git a/admin-frontend/src/locales/en.json b/admin-frontend/src/locales/en.json index d7f61e6..24aea96 100644 --- a/admin-frontend/src/locales/en.json +++ b/admin-frontend/src/locales/en.json @@ -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...", @@ -105,16 +105,16 @@ "themeTolerance": "Tolerance", "themeCustom": "Or custom...", "errorEmpty": "Please enter content", - "errorLogin": "Please login first", + "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", diff --git a/admin-frontend/src/locales/zh.json b/admin-frontend/src/locales/zh.json index b807a7f..987866b 100644 --- a/admin-frontend/src/locales/zh.json +++ b/admin-frontend/src/locales/zh.json @@ -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": "操作" }, diff --git a/admin-frontend/src/views/AdminProviders.vue b/admin-frontend/src/views/AdminProviders.vue index 6127f63..a2db1e1 100644 --- a/admin-frontend/src/views/AdminProviders.vue +++ b/admin-frontend/src/views/AdminProviders.vue @@ -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 }} diff --git a/admin-frontend/src/views/ChildProfileTimeline.vue b/admin-frontend/src/views/ChildProfileTimeline.vue index d7b3882..3cdc304 100644 --- a/admin-frontend/src/views/ChildProfileTimeline.vue +++ b/admin-frontend/src/views/ChildProfileTimeline.vue @@ -135,7 +135,7 @@ onMounted(fetchTimeline)
-

还没有开始冒险呢,快去创作第一个故事吧!

+

还没有开始冒险呢,先来创作第一个故事吧!

diff --git a/frontend/src/components/CreateStoryModal.vue b/frontend/src/components/CreateStoryModal.vue index 0dc94fc..74bf873 100644 --- a/frontend/src/components/CreateStoryModal.vue +++ b/frontend/src/components/CreateStoryModal.vue @@ -102,7 +102,7 @@ const generationSteps = computed(() => { 'Worker 会生成故事正文并保存主记录...', '主内容一可读就会自动跳转详情页...', '封面会继续在后台补全,失败也能重试...', - '马上进入故事详情页。', + '稍后会自动进入故事详情页。', ] }) diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index d7f61e6..24aea96 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -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...", @@ -105,16 +105,16 @@ "themeTolerance": "Tolerance", "themeCustom": "Or custom...", "errorEmpty": "Please enter content", - "errorLogin": "Please login first", + "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", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index b807a7f..987866b 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -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": "操作" }, diff --git a/frontend/src/utils/voiceSession.ts b/frontend/src/utils/voiceSession.ts new file mode 100644 index 0000000..86f8bff --- /dev/null +++ b/frontend/src/utils/voiceSession.ts @@ -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: '查看详情', + } +} diff --git a/frontend/src/views/ChildProfileTimeline.vue b/frontend/src/views/ChildProfileTimeline.vue index d7b3882..3cdc304 100644 --- a/frontend/src/views/ChildProfileTimeline.vue +++ b/frontend/src/views/ChildProfileTimeline.vue @@ -135,7 +135,7 @@ onMounted(fetchTimeline)
-

还没有开始冒险呢,快去创作第一个故事吧!

+

还没有开始冒险呢,先来创作第一个故事吧!

diff --git a/frontend/src/views/MyStories.vue b/frontend/src/views/MyStories.vue index d2366bc..9af4bb9 100644 --- a/frontend/src/views/MyStories.vue +++ b/frontend/src/views/MyStories.vue @@ -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', - sessionId: activeVoiceSession.value.id, - focus: 'safety', - }) - return - } - if (activeVoiceSession.value.attention_reasons.includes('failed_turn')) { - goToVoiceStudio({ - reason: 'failed_turn', - sessionId: activeVoiceSession.value.id, - focus: 'failed', - }) - return - } - goToVoiceStudio({ sessionId: activeVoiceSession.value.id }) + goToVoiceStudio({ + reason: action.reason, + sessionId: activeVoiceSession.value.id, + focus: action.focus, + }) } function getStoryLink(story: StoryItem) { @@ -316,10 +305,21 @@ watch([selectedWindow, selectedCapability, selectedVoiceWindow], () => { > 最近一轮触发了儿童内容安全兜底,建议回到工作台查看详细记录。

+
+
+ 建议动作:{{ getVoiceSessionNextStep(activeVoiceSession).label }} +
+
+ {{ getVoiceSessionNextStep(activeVoiceSession).description }} +
+
- 继续语音共创 + {{ getVoiceSessionNextAction(activeVoiceSession).label }} diff --git a/frontend/src/views/VoiceStudio.vue b/frontend/src/views/VoiceStudio.vue index c487ffa..022126b 100644 --- a/frontend/src/views/VoiceStudio.vue +++ b/frontend/src/views/VoiceStudio.vue @@ -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(null) const recordedAudioUrl = ref(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(() => { + 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(() => { + 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(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[] { + const orderedReasons: Exclude[] = [ + '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() }) @@ -1130,58 +1447,82 @@ onBeforeUnmount(() => {
- +
+ + {{ getVoiceSessionNextAction(session).label }} +
- +
@@ -1213,6 +1554,44 @@ onBeforeUnmount(() => { + +
+
+ {{ attentionCompletionNotice.completedReasonLabel }} 已处理完。 + 你可以继续切到下一类 attention,或先回到最近会话总览。 +
+
+ + + +
+
+
+
@@ -1378,6 +1757,166 @@ onBeforeUnmount(() => {
+
+
+
+

当前故事状态

+

+ 这块是把会话里的 `story_state` 翻成家长可读摘要,方便确认“故事现在讲到哪了”。 +

+
+ + {{ activeSession.can_finalize + ? '当前版本可保存' + : hasPendingConfirmation + ? '需先确认这一轮' + : activeSession.can_continue + ? '可以继续讲下一轮' + : '先查看当前状态' }} + +
+ +
+
+
当前主题
+
+ {{ storyStateSummary.premise || '还在等待第一轮主题' }} +
+
+
+
最新改写方向
+
+ {{ storyStateSummary.latestDirection || activeSession.latest_user_transcript || '暂未收到新的剧情修正' }} +
+
+
+
已生成段数
+
+ {{ storyStateSummary.segmentCount }} 段 +
+
+
+
最近意图
+
+ {{ formatIntent(activeSession.latest_detected_intent) }} +
+
+ {{ storyStateSummary.coverPromptReady ? '封面提示已准备' : '封面提示会在后续补齐' }} +
+
+
+ +
+
最近一段故事
+
+ {{ truncateNarrative(storyStateSummary.latestSegment, 140) }} +
+
+ +
+
当前可执行路径
+
+
+
+ + +
+
+
+ {{ step.title }} + + {{ step.status === 'done' ? '已完成' : step.status === 'current' ? '当前建议' : '后续步骤' }} + +
+
+ {{ step.description }} +
+
+
+ {{ step.contextLabel }} +
+
+ {{ step.contextValue }} +
+
+
+ + {{ getStoryActionStepButtonLabel(step) }} + +
+
+
+
+
+ +
+
当前系统理解
+
+ {{ activeSession.latest_understanding_summary }} +
+
+ +
+
当前故事仍带有安全标记
+
+ {{ storyStateSummary.safetyFlags.join(' / ') }} +
+
+
+
{
- 打开正式故事 + 查看正式故事
@@ -1735,7 +2274,7 @@ onBeforeUnmount(() => { />
-

故事状态快照

+

原始状态快照(调试)

{{ JSON.stringify(activeSession.story_state, null, 2) }}