feat(ui): 添加播客生成失败重试功能

添加了播客生成失败后的重试功能,包括:
- 在播客卡片中显示失败状态和重试按钮
- 保存失败任务的输入文本内容以便重试
- 实现重试事件系统,将失败内容回填到创建组件
- 更新多语言支持中的失败和重试文本

同时修复了TTS配置中的语音名称错误,调整了遮罩层透明度,
并改进了路径获取逻辑以处理根路径情况。
This commit is contained in:
hex2077
2025-10-05 22:40:49 +08:00
parent 4c46677c19
commit 13d552bb57
12 changed files with 130 additions and 13 deletions

View File

@@ -45,15 +45,15 @@
"audio": "https://podcasts.hubtoday.app/podcast/example/edgetts/zh-CN-Xiaoxiao:DragonHDFlashLatestNeural.wav"
},
{
"name": "Xiaoxiao2Flash",
"name": "XiaoxiaoFlash2",
"alias": "小笑-flash",
"code": "zh-CN-Xiaoxiao2:DragonHDFlashLatestNeural",
"code": "zh-CN-Xiaoxiao:DragonHDFlashLatestNeural2",
"locale": "zh-CN",
"gender": "Female",
"usedname": "小笑",
"volume_adjustment": 0,
"speed_adjustment": 0,
"audio": "https://podcasts.hubtoday.app/podcast/example/edgetts/zh-CN-Xiaoxiao2:DragonHDFlashLatestNeural.wav"
"audio": "https://podcasts.hubtoday.app/podcast/example/edgetts/zh-CN-Xiaoxiao:DragonHDFlashLatestNeural.wav"
},
{
"name": "YunxiaoFlash",

View File

@@ -256,6 +256,7 @@ async def _generate_podcast_task(
except Exception as e:
task_results[auth_id][task_id]["status"] = TaskStatus.FAILED
task_results[auth_id][task_id]["result"] = str(e)
task_results[auth_id][task_id]["input_txt_content"] = input_txt_content # 失败时保存输入文本
print(f"\nPodcast generation failed for task {task_id}: {e}")
finally: # 无论成功或失败,都尝试调用回调
if callback_url:
@@ -374,7 +375,8 @@ async def get_podcast_status(
"title": task_info.get("title"),
"tags": task_info.get("tags"),
"error": task_info["result"] if task_info["status"] == TaskStatus.FAILED else None,
"timestamp": task_info["timestamp"]
"timestamp": task_info["timestamp"],
"input_txt_content": task_info.get("input_txt_content"), # 添加输入文本内容
})
return {"message": "Tasks retrieved successfully.", "tasks": all_tasks_for_auth_id}

View File

@@ -340,8 +340,8 @@ def _parse_arguments():
parser.add_argument("--base-url", default="https://api.openai.com/v1", help="OpenAI API base URL (default: https://api.openai.com/v1).")
parser.add_argument("--model", default="gpt-3.5-turbo", help="OpenAI model to use (default: gpt-3.5-turbo).")
parser.add_argument("--threads", type=int, default=1, help="Number of threads to use for audio generation (default: 1).")
parser.add_argument("--output-language", type=str, default=None, help="Language for the podcast overview and script (default: Chinese).")
parser.add_argument("--usetime", type=str, default=None, help="Specific time to be mentioned in the podcast script, e.g., 10 minutes, 1 hour.")
parser.add_argument("--output-language", type=str, default="Chinese", help="Language for the podcast overview and script (default: Chinese).")
parser.add_argument("--usetime", type=str, default="Under 5 minutes", help="Specific time to be mentioned in the podcast script, e.g., 10 minutes, 1 hour.")
return parser.parse_args()

View File

@@ -53,8 +53,10 @@
"podcastCard": {
"podcastGenerationQueued": "Podcast generation queued...",
"podcastGenerating": "Podcast generating...",
"podcastGenerationFailed": "Podcast generation failed",
"moreOperations": "More operations",
"mostPopular": "Most Popular"
"mostPopular": "Most Popular",
"retry": "Retry"
},
"podcastContent": {
"speaker": "Speaker",

View File

@@ -53,8 +53,10 @@
"podcastCard": {
"podcastGenerationQueued": "ポッドキャスト生成キューに追加されました...",
"podcastGenerating": "ポッドキャスト生成中...",
"podcastGenerationFailed": "ポッドキャスト生成に失敗しました",
"moreOperations": "その他の操作",
"mostPopular": "最も人気"
"mostPopular": "最も人気",
"retry": "再試行"
},
"podcastContent": {
"speaker": "スピーカー",

View File

@@ -53,8 +53,10 @@
"podcastCard": {
"podcastGenerationQueued": "播客生成排队中...",
"podcastGenerating": "播客生成中...",
"podcastGenerationFailed": "播客生成失败",
"moreOperations": "更多操作",
"mostPopular": "最受欢迎"
"mostPopular": "最受欢迎",
"retry": "重试"
},
"podcastContent": {
"speaker": "说话人",

View File

@@ -18,7 +18,7 @@ const inter = Inter({
export async function generateMetadata({ params }: { params: { lang: string } }): Promise<Metadata> {
const { lang } = await params;
const { t } = await getTranslation(lang, 'layout');
const truePath = await getTruePathFromHeaders(await headers(), lang);
const truePath = await getTruePathFromHeaders(await headers(), lang, true);
return {
metadataBase: new URL('https://www.podcasthub.com'),
title: t('title'),

View File

@@ -77,6 +77,7 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
tags: task.tags ? task.tags.split('#').map((tag: string) => tag.trim()).filter((tag: string) => !!tag) : task.status === 'failed' ? [task.error] : [t('podcastTagsPlaceholder')],
status: task.status,
file_name: task.output_audio_filepath || '',
input_txt_content: task.input_txt_content || '',
}));
};
@@ -227,6 +228,28 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
router.push(`${truePath}/podcast/${podcast.file_name.split(".")[0]}`);
};
// 处理重试播客生成
const handleRetryPodcastGeneration = (input_txt_content: string) => {
// 在这里将 input_txt_content 回填到输入框
// 通过事件系统通知 PodcastCreator 组件
const retryEvent = new CustomEvent('retryWithContent', { detail: { input_txt_content } });
window.dispatchEvent(retryEvent);
};
// 监听重试事件
useEffect(() => {
const handleRetryEvent = (e: Event) => {
const customEvent = e as CustomEvent;
handleRetryPodcastGeneration(customEvent.detail.input_txt_content);
};
window.addEventListener('retryPodcastGeneration', handleRetryEvent);
return () => {
window.removeEventListener('retryPodcastGeneration', handleRetryEvent);
};
}, []);
const handlePlayPodcast = (podcast: PodcastItem) => {
if (currentPodcast?.id === podcast.id) {
setIsPlaying(prev => !prev);

View File

@@ -79,7 +79,7 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
<div className="w-full h-full flex items-center justify-center">
</div>
)}
{/* 播放按钮覆盖层 */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200 flex items-center justify-center opacity-0 group-hover:opacity-100">
<button
@@ -120,12 +120,38 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
</div>
{/* 遮罩层 */}
{(podcast.status === 'pending' || podcast.status === 'running') && (
<div className="absolute inset-0 bg-black/100 z-10 flex flex-col items-center justify-center text-white text-lg font-semibold p-4 text-center">
<div className="absolute inset-0 bg-black/70 z-10 flex flex-col items-center justify-center text-white text-lg font-semibold p-4 text-center backdrop-blur-sm">
<p className="mb-2">
{podcast.status === 'pending' ? t('podcastCard.podcastGenerationQueued') : t('podcastCard.podcastGenerating')}
</p>
</div>
)}
{/* 失败状态的重试按钮 */}
{podcast.status === 'failed' && podcast.input_txt_content && (
<div className="absolute inset-0 bg-black/70 z-10 flex flex-col items-center justify-center text-white text-center p-4 backdrop-blur-sm font-semibold">
<p className="mb-2 text-sm">{t('podcastCard.podcastGenerationFailed')}</p>
<button
onClick={(e) => {
e.stopPropagation();
const event = new CustomEvent('retryPodcastGeneration', {
detail: {
input_txt_content: podcast.input_txt_content || ''
}
});
window.dispatchEvent(event);
}}
className="px-4 py-2 hover:bg-brand-purple rounded-md text-sm transition-colors flex items-center gap-2 text-white hover:border-brand-purple"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-refresh-cw">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
{t('podcastCard.retry')}
</button>
</div>
)}
</div>
);
}
@@ -270,6 +296,33 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
</div>
)}
</div>
{/* 失败状态的重试按钮 - 仅在大卡片模式下显示 */}
{podcast.status === 'failed' && podcast.input_txt_content && (
<div className="absolute inset-0 bg-black/70 z-10 flex flex-col items-center justify-center text-white text-center p-4 backdrop-blur-sm font-semibold">
<p className="mb-2 text-sm">{t('podcastCard.podcastGenerationFailed')}</p>
<button
onClick={(e) => {
e.stopPropagation();
const event = new CustomEvent('retryPodcastGeneration', {
detail: {
input_txt_content: podcast.input_txt_content || ''
}
});
window.dispatchEvent(event);
}}
className="px-4 py-2 hover:bg-brand-purple rounded-md text-sm transition-colors flex items-center gap-2 text-white hover:border-brand-purple"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-refresh-cw">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
{t('podcastCard.retry')}
</button>
</div>
)}
</div>
);
};

View File

@@ -80,6 +80,35 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
}
}, []);
// 监听重试事件以回填输入框内容
useEffect(() => {
const handleRetryEvent = (e: Event) => {
const customEvent = e as CustomEvent;
const content = customEvent.detail.input_txt_content;
// 如果内容包含自定义指令的标记,将其分离
if (content.includes('```custom-begin') && content.includes('```custom-end')) {
const customBeginIndex = content.indexOf('```custom-begin');
const customEndIndex = content.indexOf('```custom-end') + 13; // 13 is the length of '```custom-end'
const customInstruction = content.substring(customBeginIndex + 15, customEndIndex - 13); // 15 is the length of '```custom-begin'
const topicContent = content.substring(customEndIndex + 1).trim(); // +1 to remove the newline after custom-end
setTopic(topicContent);
setCustomInstructions(customInstruction);
} else {
// 如果没有自定义指令标记,整个内容都是主题
setTopic(content);
}
};
window.addEventListener('retryWithContent', handleRetryEvent);
return () => {
window.removeEventListener('retryWithContent', handleRetryEvent);
};
}, []);
const getInitialLanguage = (currentLang: string) => {
if (currentLang.startsWith('zh')) {
return 'Chinese';

View File

@@ -195,11 +195,14 @@ import path from 'path';
/**
* 从请求头和参数中获取真实的路径
*/
export async function getTruePathFromHeaders(headersList: ReadonlyHeaders, langParam: string): Promise<string> {
export async function getTruePathFromHeaders(headersList: ReadonlyHeaders, langParam: string, isRoot: boolean = false): Promise<string> {
const pathname = headersList.get('x-next-pathname') || '';
// console.log('Current pathname (from server):', pathname);
// console.log('langParam:', langParam);
if (pathname === '' || !languages.includes(pathname)) {
if (isRoot) {
return '/';
}
return '';
}
return pathname !== langParam ? "/" : "/"+langParam;

View File

@@ -132,6 +132,7 @@ export interface PodcastItem {
tags: string[];
status: 'pending' | 'running' | 'completed' | 'failed'; // 添加status属性
file_name: string;
input_txt_content?: string; // 新增 input_txt_content 字段
}
// 设置表单数据类型 - 从 SettingsForm.tsx 复制过来并导出