feat(podcast): 添加沉浸故事模式支持多语言播客生成

新增沉浸故事生成模式,支持原文朗读和智能分段:
- 服务端新增generate_podcast_with_story_api函数和专用API端点
- 添加故事模式专用prompt模板(prompt-story-overview.txt和prompt-story-podscript.txt)
- 前端新增模式切换UI,支持AI播客和沉浸故事两种模式
- 沉浸故事模式固定消耗30积分,不需要语言和时长参数
- 优化音频静音裁剪逻辑,保留首尾200ms空白提升自然度
- 修复session管理和错误处理,提升系统稳定性
- 新增多语言配置(中英日)支持模式切换文案
This commit is contained in:
hex2077
2025-10-19 22:09:13 +08:00
parent 321e3cded4
commit dd2a1b536f
18 changed files with 672 additions and 116 deletions

View File

@@ -103,7 +103,9 @@
"english": "English",
"japanese": "Japanese",
"under5Minutes": "5 minutes or less",
"between8And15Minutes": "8-15 minutes"
"between8And15Minutes": "8-15 minutes",
"aiPodcast": "AI Podcast",
"immersiveStory": "Immersive Story"
},
"podcastTabs": {
"script": "Script",

View File

@@ -103,7 +103,9 @@
"english": "英語",
"japanese": "日本語",
"under5Minutes": "約5分",
"between8And15Minutes": "8〜15分"
"between8And15Minutes": "8〜15分",
"aiPodcast": "AIポッドキャスト",
"immersiveStory": "没入型ストーリー"
},
"podcastTabs": {
"script": "スクリプト",

View File

@@ -103,7 +103,9 @@
"english": "英文",
"japanese": "日文",
"under5Minutes": "5分钟左右",
"between8And15Minutes": "8-15分钟"
"between8And15Minutes": "8-15分钟",
"aiPodcast": "AI播客",
"immersiveStory": "沉浸故事"
},
"podcastTabs": {
"script": "脚本",

View File

@@ -169,8 +169,13 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
try {
// info('开始生成播客', '正在处理您的请求...');
// 根据模式选择不同的 API 端点
const apiEndpoint = request.mode === 'ai-story'
? '/api/generate-podcast-with-story'
: '/api/generate-podcast';
// 直接发送JSON格式的请求体
const response = await fetch('/api/generate-podcast', {
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from 'next/server';
import { startPodcastWithStoryGenerationTask } from '@/lib/podcastApi';
import type { PodcastGenerationRequest } from '@/types';
import { getSessionData } from '@/lib/server-actions';
import { getUserPoints } from '@/lib/points';
import { fetchAndCacheProvidersLocal } from '@/lib/config-local';
import { getTranslation } from '@/i18n';
import { getLanguageFromRequest } from '@/lib/utils';
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
export async function POST(request: NextRequest) {
const lang = getLanguageFromRequest(request);
const { t } = await getTranslation(lang, 'errors');
const session = await getSessionData();
const userId = session.user?.id;
if (!userId) {
return NextResponse.json(
{ success: false, error: t('user_not_logged_in_or_session_expired') },
{ status: 403 }
);
}
try {
const body: PodcastGenerationRequest = await request.json();
// 参数校验
if (!body.input_txt_content || body.input_txt_content.trim().length === 0) {
return NextResponse.json(
{ success: false, error: t('request_body_cannot_be_empty') },
{ status: 400 }
);
}
if (!body.tts_provider || body.tts_provider.trim().length === 0) {
return NextResponse.json(
{ success: false, error: t('tts_provider_cannot_be_empty') },
{ status: 400 }
);
}
let podUsers: any[] = [];
try {
podUsers = JSON.parse(body.podUsers_json_content || '[]');
if (podUsers.length === 0) {
return NextResponse.json(
{ success: false, error: t('please_select_at_least_one_speaker') },
{ status: 400 }
);
}
} catch (e) {
return NextResponse.json(
{ success: false, error: t('invalid_speaker_config_format') },
{ status: 400 }
);
}
// 1. 查询用户积分
const currentPoints = await getUserPoints(userId);
const POINTS_PER_STORY = 30; // 沉浸故事模式固定消耗30积分
// 2. 检查积分是否足够
if (currentPoints === null || currentPoints < POINTS_PER_STORY) {
return NextResponse.json(
{ success: false, error: t('insufficient_points_for_podcast', { pointsNeeded: POINTS_PER_STORY, currentPoints: currentPoints || 0 }) },
{ status: 402 }
);
}
// 沉浸故事模式不需要验证语言和时长参数
// 根据 enableTTSConfigPage 构建最终的 request
let finalRequest: PodcastGenerationRequest;
if (enableTTSConfigPage) {
// 如果启用配置页面,则直接使用前端传入的 body
if (body.tts_providers_config_content === undefined || body.api_key === undefined || body.base_url === undefined || body.model === undefined) {
return NextResponse.json(
{ success: false, error: t('missing_frontend_tts_config') },
{ status: 400 }
);
}
finalRequest = body as PodcastGenerationRequest;
} else {
// 如果未启用配置页面,则在后端获取 TTS 配置
const settings = await fetchAndCacheProvidersLocal(lang);
if (!settings || !settings.apikey || !settings.model) {
return NextResponse.json(
{ success: false, error: t('incomplete_backend_tts_config') },
{ status: 500 }
);
}
finalRequest = {
input_txt_content: body.input_txt_content,
tts_provider: body.tts_provider,
podUsers_json_content: body.podUsers_json_content,
tts_providers_config_content: JSON.stringify(settings),
api_key: settings.apikey,
base_url: settings.baseurl,
model: settings.model,
} as PodcastGenerationRequest;
}
const callback_url = process.env.NEXT_PUBLIC_PODCAST_CALLBACK_URL || ""
finalRequest.callback_url = callback_url;
// 调用沉浸故事生成任务
const result = await startPodcastWithStoryGenerationTask(finalRequest, userId, lang);
if (result.success) {
return NextResponse.json({
success: true,
data: result.data,
});
} else {
return NextResponse.json(
{ success: false, error: result.error },
{ status: result.statusCode || 400 }
);
}
} catch (error: any) {
console.error('Error in generate-podcast-with-story API:', error);
const statusCode = error.statusCode || 500;
return NextResponse.json(
{ success: false, error: error.message || t('internal_server_error_default') },
{ status: statusCode }
);
}
}

View File

@@ -58,11 +58,16 @@ export async function POST(request: NextRequest) {
// 1. 查询用户积分
const currentPoints = await getUserPoints(userId);
const POINTS_PER_PODCAST = parseInt(process.env.POINTS_PER_PODCAST || '10', 10); // 从环境变量获取默认10
// 2. 检查积分是否足够
if (currentPoints === null || currentPoints < POINTS_PER_PODCAST) {
// 2. 根据时长计算需要的积分
let pointsToDeduct = parseInt(process.env.POINTS_PER_PODCAST || '10', 10); // 从环境变量获取默认10
if(body.usetime === '8-15 minutes') {
pointsToDeduct = pointsToDeduct * 2;
}
// 3. 检查积分是否足够
if (currentPoints === null || currentPoints < pointsToDeduct) {
return NextResponse.json(
{ success: false, error: t('insufficient_points_for_podcast', { pointsNeeded: POINTS_PER_PODCAST, currentPoints: currentPoints || 0 }) },
{ success: false, error: t('insufficient_points_for_podcast', { pointsNeeded: pointsToDeduct, currentPoints: currentPoints || 0 }) },
{ status: 402 } // 402 Forbidden - 权限不足,因为积分不足
);
}

View File

@@ -30,7 +30,7 @@ export async function GET(request: NextRequest) { // GET 函数接收 request
}
export async function PUT(request: NextRequest) {
const { task_id, auth_id, timestamp, status, usetime, lang } = await request.json();
const { task_id, auth_id, timestamp, status, usetime, mode, lang } = await request.json();
const { t } = await getTranslation(lang, 'errors'); // 初始化翻译
try {
if(status !== 'completed') {
@@ -62,9 +62,16 @@ export async function PUT(request: NextRequest) {
const userId = auth_id; // 这里假设 auth_id 就是 userId
// 5. 扣减积分
let pointsToDeduct = parseInt(process.env.POINTS_PER_PODCAST || '10', 10); // 从环境变量获取默认10
if(usetime === '8-15 minutes') {
pointsToDeduct = pointsToDeduct * 2;
let pointsToDeduct: number;
if (mode === 'ai-story') {
// 沉浸故事模式固定消耗30积分
pointsToDeduct = 30;
} else {
// AI播客模式根据时长计算积分
pointsToDeduct = parseInt(process.env.POINTS_PER_PODCAST || '10', 10);
if(usetime === '8-15 minutes') {
pointsToDeduct = pointsToDeduct * 2;
}
}
const reasonCode = "podcast_generation";

View File

@@ -9,6 +9,7 @@ import {
AiOutlineGlobal,
AiOutlineDown,
AiOutlineLoading3Quarters,
AiOutlineStar
} from 'react-icons/ai';
import {
Wand2,
@@ -66,7 +67,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
const [topic, setTopic] = useState('');
const [customInstructions, setCustomInstructions] = useState('');
const [selectedMode, setSelectedMode] = useState<'ai-podcast' | 'flowspeech'>('ai-podcast');
const [selectedMode, setSelectedMode] = useState<'ai-podcast' | 'ai-story'>('ai-podcast');
// 初始化时从 localStorage 加载 topic 和 customInstructions
useEffect(() => {
@@ -144,7 +145,14 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
const fileInputRef = useRef<HTMLInputElement>(null);
const { toasts, error, success, removeToast } = useToast(); // 使用 useToast hook, 引入 success
const { data: session } = useSession(); // 获取 session
const { data: session, isPending, error: sessionError } = useSession(); // 获取 session 及其状态
// 处理 session 错误
useEffect(() => {
if (sessionError) {
console.error('Session error:', sessionError);
}
}, [sessionError]);
const handleSubmit = async () => { // 修改为 async 函数
if (!session?.user) { // 判断是否登录
@@ -171,16 +179,18 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
const handleConfirmGenerate = async () => {
let inputTxtContent = topic.trim();
if (customInstructions.trim()) {
// 只在 AI 播客模式下添加自定义指令
if (selectedMode === 'ai-podcast' && customInstructions.trim()) {
inputTxtContent = "```custom-begin"+`\n${customInstructions.trim()}\n`+"```custom-end"+`\n${inputTxtContent}`;
}
const request: PodcastGenerationRequest = {
// 根据模式构建不同的请求参数
const baseRequest = {
tts_provider: selectedConfigName.replace('.json', ''),
input_txt_content: inputTxtContent,
podUsers_json_content: JSON.stringify(selectedPodcastVoices[selectedConfigName] || []),
usetime: duration,
output_language: language,
mode: selectedMode, // 添加模式标识
...(enableTTSConfigPage ? {
tts_providers_config_content: JSON.stringify(settings),
api_key: settings?.apikey,
@@ -189,6 +199,15 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
} : {})
};
// 只在 AI 播客模式下添加语言和时长参数
const request: PodcastGenerationRequest = selectedMode === 'ai-podcast'
? {
...baseRequest,
usetime: duration,
output_language: language,
}
: baseRequest;
try {
await onGenerate(request); // 等待 API 调用完成
// 清空 topic 和 customInstructions并更新 localStorage
@@ -312,7 +331,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
</h1>
{/* 模式切换按钮 todo */}
{/* <div className="flex items-center justify-center gap-2 sm:gap-4 mb-8 flex-wrap">
<div className="flex items-center justify-center gap-2 sm:gap-4 mb-8 flex-wrap">
<button
onClick={() => setSelectedMode('ai-podcast')}
className={cn(
@@ -323,21 +342,21 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
)}
>
<AiFillPlayCircle className="w-4 h-4" />
AI播客
{t('podcastCreator.aiPodcast')}
</button>
<button
onClick={() => setSelectedMode('flowspeech')}
onClick={() => setSelectedMode('ai-story')}
className={cn(
"flex items-center gap-2 px-4 py-2 sm:px-6 sm:py-3 rounded-full font-medium transition-all duration-200",
selectedMode === 'flowspeech'
selectedMode === 'ai-story'
? "btn-primary"
: "btn-secondary"
)}
>
<AiOutlineStar className="w-4 h-4" />
FlowSpeech
{t('podcastCreator.immersiveStory')}
</button>
</div> */}
</div>
</div>
{/* 主要创作区域 */}
@@ -356,7 +375,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
/>
{/* 自定义指令 */}
{customInstructions !== undefined && (
{customInstructions !== undefined && selectedMode === 'ai-podcast' && (
<div className="mt-4 pt-4 border-t border-neutral-100">
<textarea
value={customInstructions}
@@ -405,38 +424,42 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
</button>
{/* 语言选择 */}
<div className="relative w-[120px]">
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="appearance-none w-full bg-white border border-neutral-200 rounded-lg px-3 py-2 pr-8 text-sm font-medium text-neutral-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200 shadow-sm hover:shadow-md hover:border-neutral-300 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isGenerating}
>
{languageOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<AiOutlineDown className="absolute right-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
</div>
{selectedMode === 'ai-podcast' && (
<div className="relative w-[120px]">
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="appearance-none w-full bg-white border border-neutral-200 rounded-lg px-3 py-2 pr-8 text-sm font-medium text-neutral-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200 shadow-sm hover:shadow-md hover:border-neutral-300 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isGenerating}
>
{languageOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<AiOutlineDown className="absolute right-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
</div>
)}
{/* 时长选择 */}
<div className="relative w-[120px]">
<select
value={duration}
onChange={(e) => setDuration(e.target.value as any)}
className="appearance-none w-full bg-white border border-neutral-200 rounded-lg px-3 py-2 pr-8 text-sm font-medium text-neutral-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200 shadow-sm hover:shadow-md hover:border-neutral-300 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isGenerating}
>
{durationOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<AiOutlineDown className="absolute right-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
</div>
{selectedMode === 'ai-podcast' && (
<div className="relative w-[120px]">
<select
value={duration}
onChange={(e) => setDuration(e.target.value as any)}
className="appearance-none w-full bg-white border border-neutral-200 rounded-lg px-3 py-2 pr-8 text-sm font-medium text-neutral-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200 shadow-sm hover:shadow-md hover:border-neutral-300 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isGenerating}
>
{durationOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<AiOutlineDown className="absolute right-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
</div>
)}
{/* 积分显示 */}
<div className="w-[120px] flex items-center justify-center gap-1.5 px-3 py-2 bg-white border border-neutral-200 rounded-lg shadow-sm">
@@ -575,9 +598,11 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
onConfirm={handleConfirmGenerate}
title={t('podcastCreator.confirmGeneration')}
message={t('podcastCreator.confirmGenerationMessage')}
points={duration === '8-15 minutes' ?
parseInt(process.env.POINTS_PER_PODCAST || '20', 10) * 2 :
parseInt(process.env.POINTS_PER_PODCAST || '20', 10)}
points={selectedMode === 'ai-story'
? 30
: (duration === '8-15 minutes'
? parseInt(process.env.POINTS_PER_PODCAST || '20', 10) * 2
: parseInt(process.env.POINTS_PER_PODCAST || '20', 10))}
lang={lang}
/>
</div>

View File

@@ -29,15 +29,17 @@ export default function PodcastTabs({ parsedScript, overviewContent, lang }: Pod
>
{t('podcastTabs.script')}
</button>
{/* 大纲 */}
<button
className={`py-4 px-1 text-base font-semibold ${
activeTab === 'overview' ? 'text-gray-900 border-b-2 border-gray-900' : 'text-gray-500 border-b-2 border-transparent hover:text-gray-900'
}`}
onClick={() => setActiveTab('overview')}
>
{t('podcastTabs.outline')}
</button>
{/* 大纲 - 仅在有内容时显示 */}
{overviewContent && (
<button
className={`py-4 px-1 text-base font-semibold ${
activeTab === 'overview' ? 'text-gray-900 border-b-2 border-gray-900' : 'text-gray-500 border-b-2 border-transparent hover:text-gray-900'
}`}
onClick={() => setActiveTab('overview')}
>
{t('podcastTabs.outline')}
</button>
)}
</div>
</div>
</div>

View File

@@ -71,32 +71,43 @@ const Sidebar: React.FC<SidebarProps> = ({
if (!didFetch.current) {
didFetch.current = true; // 标记为已执行,避免在开发模式下重复执行
const fetchSession = async () => {
const { session: fetchedSession, user: fetchedUser } = await getSessionData();
setSession(fetchedSession);
console.log('session', fetchedSession); // 确保只在 session 数据获取并设置后打印
try {
const { session: fetchedSession, user: fetchedUser } = await getSessionData();
setSession(fetchedSession);
console.log('session', fetchedSession); // 确保只在 session 数据获取并设置后打印
} catch (error) {
console.error('Failed to fetch session:', error);
// 如果获取 session 失败,不要无限重试
setSession(null);
}
};
fetchSession();
}
}, []); // 只在组件挂载时执行一次
// 检查 session 是否过期
if (session?.expiresAt) {
const expirationTime = session.expiresAt.getTime();
const currentTime = new Date().getTime();
// 单独的 effect 用于检查 session 过期
useEffect(() => {
if (!session?.expiresAt) return;
if (currentTime > expirationTime) {
console.log(t('sidebar.sessionExpired'));
signOut({
fetchOptions: {
onSuccess: () => {
setSession(null); // 会话过期,注销成功后清空本地 session 状态
onCreditsChange(0); // 清空积分
router.push(truePath+"/"); // 会话过期,执行注销并重定向到主页
},
const expirationTime = session.expiresAt.getTime();
const currentTime = new Date().getTime();
if (currentTime > expirationTime) {
console.log(t('sidebar.sessionExpired'));
signOut({
fetchOptions: {
onSuccess: () => {
setSession(null); // 会话过期,注销成功后清空本地 session 状态
onCreditsChange(0); // 清空积分
router.push(truePath+"/"); // 会话过期,执行注销并重定向到主页
},
});
}
onError: (error) => {
console.error('Sign out error:', error);
},
},
});
}
}, [session, router, onCreditsChange, t]); // 监听 session 变化和 router因为 signOut 中使用了 router.push并添加 onCreditsChange
}, [session?.expiresAt, router, onCreditsChange, t, truePath]); // 监听必要的依赖
// todo
const mainNavItems: NavItem[] = [

View File

@@ -4,5 +4,10 @@ import { usernameClient } from "better-auth/client/plugins";
export const { signIn, signUp, signOut, useSession, updateUser, changeEmail, changePassword} =
createAuthClient({
plugins: [usernameClient()],
baseURL: process.env.BETTER_AUTH_URL!,
baseURL: process.env.NEXT_PUBLIC_BASE_URL || process.env.BETTER_AUTH_URL || 'http://localhost:3000',
fetchOptions: {
onError: (ctx) => {
console.error('Auth client error:', ctx.error);
},
},
});

View File

@@ -38,6 +38,41 @@ export async function startPodcastGenerationTask(body: PodcastGenerationRequest,
}
}
/**
* 启动沉浸故事模式的播客生成任务(不传递自定义指令、语言和时长参数)
*/
export async function startPodcastWithStoryGenerationTask(body: PodcastGenerationRequest, userId: string, lang: string): Promise<ApiResponse<PodcastGenerationResponse>> {
body.lang = lang;
try {
// 创建一个新的请求体,排除 customInstructions、output_language 和 usetime 参数
const { output_language, usetime, ...storyBody } = body;
const response = await fetch(`${API_BASE_URL}/generate-podcast-with-story`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Auth-Id': userId,
},
body: new URLSearchParams(Object.entries(storyBody).map(([key, value]) => [key, String(value)])),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: `请求失败,状态码: ${response.status}` }));
throw new HttpError(errorData.detail || `请求失败,状态码: ${response.status}`, response.status);
}
const result: PodcastGenerationResponse = await response.json();
// 确保id字段存在因为它在前端被广泛使用
result.id = result.task_id;
return { success: true, data: result };
} catch (error: any) {
console.error('Error in startPodcastWithStoryGenerationTask:', error);
const statusCode = error instanceof HttpError ? error.statusCode : undefined;
return { success: false, error: error.message || '启动沉浸故事生成任务失败', statusCode };
}
}
/**
* 获取播客生成任务状态
*/

View File

@@ -11,6 +11,7 @@ export interface PodcastGenerationRequest {
usetime?: string; // 时长 来自选择
output_language?: string; // 语言 来自设置
lang?: string; // 子路径表示语言
mode?: 'ai-podcast' | 'ai-story'; // 模式标识AI播客或沉浸故事
}
export interface PodcastGenerationResponse {