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:
@@ -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",
|
||||
|
||||
@@ -103,7 +103,9 @@
|
||||
"english": "英語",
|
||||
"japanese": "日本語",
|
||||
"under5Minutes": "約5分",
|
||||
"between8And15Minutes": "8〜15分"
|
||||
"between8And15Minutes": "8〜15分",
|
||||
"aiPodcast": "AIポッドキャスト",
|
||||
"immersiveStory": "没入型ストーリー"
|
||||
},
|
||||
"podcastTabs": {
|
||||
"script": "スクリプト",
|
||||
|
||||
@@ -103,7 +103,9 @@
|
||||
"english": "英文",
|
||||
"japanese": "日文",
|
||||
"under5Minutes": "5分钟左右",
|
||||
"between8And15Minutes": "8-15分钟"
|
||||
"between8And15Minutes": "8-15分钟",
|
||||
"aiPodcast": "AI播客",
|
||||
"immersiveStory": "沉浸故事"
|
||||
},
|
||||
"podcastTabs": {
|
||||
"script": "脚本",
|
||||
|
||||
@@ -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',
|
||||
|
||||
131
web/src/app/api/generate-podcast-with-story/route.ts
Normal file
131
web/src/app/api/generate-podcast-with-story/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 - 权限不足,因为积分不足
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取播客生成任务状态
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface PodcastGenerationRequest {
|
||||
usetime?: string; // 时长 来自选择
|
||||
output_language?: string; // 语言 来自设置
|
||||
lang?: string; // 子路径表示语言
|
||||
mode?: 'ai-podcast' | 'ai-story'; // 模式标识:AI播客或沉浸故事
|
||||
}
|
||||
|
||||
export interface PodcastGenerationResponse {
|
||||
|
||||
Reference in New Issue
Block a user