diff --git a/.gitignore b/.gitignore index 3434147..95d4e3e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ __pycache__/ output/ excalidraw.log config/tts_providers-local.json +.claude +.serena \ No newline at end of file diff --git a/main.py b/main.py index 493b4cf..0bfd000 100644 --- a/main.py +++ b/main.py @@ -41,7 +41,7 @@ async def lifespan(app: FastAPI): os.makedirs(output_dir, exist_ok=True) # 安排清理任务每30分钟运行一次 - schedule.every(30).minutes.do(clean_output_directory) + schedule.every(time_after).minutes.do(clean_output_directory) # 在单独的线程中启动调度器 scheduler_thread = threading.Thread(target=run_scheduler, daemon=True) @@ -74,6 +74,7 @@ stop_scheduler_event = threading.Event() # 全局配置 output_dir = "output" +time_after = 30 # 定义一个函数来清理输出目录 def clean_output_directory(): @@ -81,7 +82,10 @@ def clean_output_directory(): print(f"Cleaning output directory: {output_dir}") now = time.time() # 30 minutes in seconds - threshold = 30 * 60 + threshold = time_after * 60 + + # 存储需要删除的 task_results 中的任务,避免在迭代时修改 + tasks_to_remove_from_memory = [] for filename in os.listdir(output_dir): file_path = os.path.join(output_dir, filename) @@ -91,6 +95,17 @@ def clean_output_directory(): if now - os.path.getmtime(file_path) > threshold: os.unlink(file_path) print(f"Deleted old file: {file_path}") + + # 遍历 task_results,查找匹配的文件名 + # 注意:task_info["output_audio_filepath"] 存储的是文件名,不是完整路径 + for auth_id, tasks_by_auth in task_results.items(): + # 使用 list() 创建副本,以安全地在循环中删除元素 + for task_id, task_info in list(tasks_by_auth.items()): + # 检查文件名是否匹配,并且任务状态为 COMPLETED + # 只有 COMPLETED 的任务才应该被清理,PENDING/RUNNING 任务的输出文件可能还未生成或正在使用 + task_output_filename = task_info.get("output_audio_filepath") + if task_output_filename == filename and task_info["status"] == TaskStatus.COMPLETED: + tasks_to_remove_from_memory.append((auth_id, task_id)) elif os.path.isdir(file_path): # 可选地,递归删除旧的子目录或其中的文件 # 目前只跳过目录 @@ -98,6 +113,16 @@ def clean_output_directory(): except Exception as e: print(f"Failed to delete {file_path}. Reason: {e}") + # 在文件删除循环结束后统一处理 task_results 的删除 + for auth_id, task_id in tasks_to_remove_from_memory: + if auth_id in task_results and task_id in task_results[auth_id]: + del task_results[auth_id][task_id] + print(f"Removed task {task_id} for auth_id {auth_id} from task_results.") + # 如果该 auth_id 下没有其他任务,则删除 auth_id 的整个条目 + if not task_results[auth_id]: + del task_results[auth_id] + print(f"Removed empty auth_id {auth_id} from task_results.") + # 内存中存储任务结果 # {task_id: {"auth_id": auth_id, "status": TaskStatus, "result": any, "timestamp": float}} task_results: Dict[str, Dict[UUID, Dict]] = {} @@ -337,7 +362,7 @@ async def get_voices(tts_provider: str = "tts"): try: with open(config_path, 'r', encoding='utf-8') as f: config_data = json.load(f) - + voices = config_data.get("voices") if voices is None: raise HTTPException(status_code=404, detail=f"No 'voices' key found in config for {tts_provider}.") diff --git a/podcast_generator.py b/podcast_generator.py index a0e1536..7940f61 100644 --- a/podcast_generator.py +++ b/podcast_generator.py @@ -112,8 +112,11 @@ def generate_speaker_id_text(pod_users, voices_list): return "。".join(speaker_info) + "。" def merge_audio_files(): - output_audio_filename = f"podcast_{int(time.time())}.wav" - output_audio_filepath = "/".join([output_dir, output_audio_filename]) + timestamp = int(time.time()) + output_audio_filename_wav = f"podcast_{timestamp}.wav" + output_audio_filepath_wav = os.path.join(output_dir, output_audio_filename_wav) + output_audio_filename_mp3 = f"podcast_{timestamp}.mp3" + output_audio_filepath_mp3 = os.path.join(output_dir, output_audio_filename_mp3) # Use ffmpeg to concatenate audio files # Check if ffmpeg is available @@ -122,7 +125,7 @@ def merge_audio_files(): except FileNotFoundError: raise RuntimeError("FFmpeg is not installed or not in your PATH. Please install FFmpeg to merge audio files. You can download FFmpeg from: https://ffmpeg.org/download.html") - print(f"\nMerging audio files into {output_audio_filename}...") + print(f"\nMerging audio files into {output_audio_filename_wav}...") try: command = [ "ffmpeg", @@ -132,18 +135,34 @@ def merge_audio_files(): "-acodec", "pcm_s16le", "-ar", "44100", "-ac", "2", - output_audio_filename + output_audio_filename_wav # Output to WAV first ] # Execute ffmpeg from the output_dir to correctly resolve file paths in file_list.txt process = subprocess.run(command, check=True, cwd=output_dir, capture_output=True, text=True) - print(f"Audio files merged successfully into {output_audio_filepath}!") + print(f"Audio files merged successfully into {output_audio_filepath_wav}!") print("FFmpeg stdout:\n", process.stdout) print("FFmpeg stderr:\n", process.stderr) - return output_audio_filename + + # Convert WAV to MP3 + print(f"Converting {output_audio_filename_wav} to {output_audio_filename_mp3} (high quality)...") + mp3_command = [ + "ffmpeg", + "-i", output_audio_filename_wav, + "-vn", # No video + "-b:a", "192k", # Audio bitrate to 192kbps for high quality + "-acodec", "libmp3lame", # Use libmp3lame for MP3 encoding + output_audio_filename_mp3 + ] + mp3_process = subprocess.run(mp3_command, check=True, cwd=output_dir, capture_output=True, text=True) + print(f"Conversion to MP3 successful! Output: {output_audio_filepath_mp3}") + print("FFmpeg MP3 stdout:\n", mp3_process.stdout) + print("FFmpeg MP3 stderr:\n", mp3_process.stderr) + + return output_audio_filename_mp3 # Return the MP3 filename except subprocess.CalledProcessError as e: - raise RuntimeError(f"Error merging audio files with FFmpeg: {e.stderr}") + raise RuntimeError(f"Error merging or converting audio files with FFmpeg: {e.stderr}") finally: - # Clean up temporary audio files and the file list + # Clean up temporary audio files, the file list, and the intermediate WAV file for item in os.listdir(output_dir): if item.startswith("temp_audio"): try: @@ -154,6 +173,12 @@ def merge_audio_files(): os.remove(file_list_path) except OSError as e: print(f"Error removing file list {file_list_path}: {e}") # This should not stop the process + try: + if os.path.exists(output_audio_filepath_wav): + os.remove(output_audio_filepath_wav) + print(f"Cleaned up intermediate WAV file: {output_audio_filename_wav}") + except OSError as e: + print(f"Error removing intermediate WAV file {output_audio_filepath_wav}: {e}") print("Cleaned up temporary files.") def get_audio_duration(filepath: str) -> Optional[float]: @@ -575,7 +600,6 @@ def generate_podcast_audio_api(args, config_path: str, input_txt_content: str, t # Assuming `output_language` is passed directly to the function podscript_prompt, pod_users, voices, turn_pattern = _prepare_podcast_prompts(config_data, original_podscript_prompt, custom_content, args.usetime, args.output_language) - print(f"\nInput Prompt (from provided content):\n{input_prompt[:100]}...") print(f"\nOverview Prompt (prompt-overview.txt):\n{overview_prompt[:100]}...") print(f"\nPodscript Prompt (prompt-podscript.txt):\n{podscript_prompt[:1000]}...") diff --git a/prompt/prompt-overview.txt b/prompt/prompt-overview.txt index 57943ff..3eae941 100644 --- a/prompt/prompt-overview.txt +++ b/prompt/prompt-overview.txt @@ -1,6 +1,6 @@ - {{outlang}} - - Based on the key content of the document, without using content from titles or subtitles, create a document summary of around 150 characters. Then, based on the summary's content, generate a title and place it on the first line of the output. + - Create a summary of approximately 150 characters based on the core content of the document, without using the title or subtitle. Then, generate a title of approximately 15-20 characters based on the summary, such as "The Four Mirrors of Human Nature: See Through Selfishness and Admiration for Strength, and Live More Transparently." Place this title on the first line of the output. - Generate tags based on the document's content, without using the content from titles or subtitles, separated by the # symbol. Generate 3-5 tags. The tags should not be summative; they should be excerpted from words within the document and placed on the second line of the output. diff --git a/web/.claude/settings.local.json b/web/.claude/settings.local.json index af1c485..40213c1 100644 --- a/web/.claude/settings.local.json +++ b/web/.claude/settings.local.json @@ -9,7 +9,18 @@ "Bash(npm run lint)", "Bash(timeout:*)", "Bash(npm run dev)", - "Bash(node:*)" + "Bash(node:*)", + "mcp__serena__check_onboarding_performed", + "mcp__serena__activate_project", + "mcp__serena__onboarding", + "mcp__serena__list_dir", + "mcp__serena__get_symbols_overview", + "mcp__serena__find_symbol", + "mcp__serena__think_about_collected_information", + "mcp__serena__write_memory", + "mcp__serena__think_about_whether_you_are_done", + "Bash(grep:*)", + "mcp__serena__search_for_pattern" ], "deny": [] } diff --git a/web/.env.example b/web/.env.example deleted file mode 100644 index 4d404b7..0000000 --- a/web/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# OpenAI API配置 -OPENAI_API_KEY=your_openai_api_key_here -OPENAI_BASE_URL=https://api.openai.com/v1 -OPENAI_MODEL=gpt-3.5-turbo - -# Next.js配置 -NEXT_PUBLIC_APP_URL=http://localhost:3000 - -# Python脚本路径(相对于web2目录) -PYTHON_SCRIPT_PATH=../podcast_generator.py -OUTPUT_DIR=../output -INPUT_FILE=../input.txt - -# 开发模式配置 -NODE_ENV=development \ No newline at end of file diff --git a/web/API_DUPLICATE_CALL_FIX.md b/web/API_DUPLICATE_CALL_FIX.md new file mode 100644 index 0000000..e9980f9 --- /dev/null +++ b/web/API_DUPLICATE_CALL_FIX.md @@ -0,0 +1,189 @@ +# API重复调用问题修复报告 + +## 问题描述 + +用户报告访问主页时,主页内的接口被调用了两次。这是一个常见的React问题,会导致: +- 不必要的网络请求 +- 服务器负载增加 +- 用户体验下降 +- 可能的数据不一致 + +## 问题分析 + +通过代码分析,发现了以下导致重复调用的原因: + +### 1. **多个useEffect调用同一API** +在 `src/app/page.tsx` 中: +- 第38行的useEffect在组件挂载时调用 `fetchRecentPodcasts()` +- 第86行的useEffect设置定时器每20秒调用 `fetchRecentPodcasts()` +- 这导致页面加载时API被调用两次 + +### 2. **useEffect依赖项问题** +在 `src/components/PodcastCreator.tsx` 中: +- useEffect依赖项包含 `selectedConfig` 和 `selectedConfigName` +- 当配置变化时可能触发多次API调用 + +### 3. **ConfigSelector组件的重复调用** +在 `src/components/ConfigSelector.tsx` 中: +- localStorage变化监听可能导致重复的配置加载 + +## 修复方案 + +### 1. **合并useEffect调用** +将两个分离的useEffect合并为一个: + +```typescript +// 修复前:两个独立的useEffect +useEffect(() => { + setCredits(100000); + fetchRecentPodcasts(); // 第一次调用 +}, []); + +useEffect(() => { + const interval = setInterval(() => { + fetchRecentPodcasts(); // 定时调用 + }, 20000); + return () => clearInterval(interval); +}, []); + +// 修复后:合并为一个useEffect +useEffect(() => { + setCredits(100000); + fetchRecentPodcasts(); // 初始调用 + + // 设置定时器 + const interval = setInterval(() => { + fetchRecentPodcasts(); + }, 20000); + + return () => clearInterval(interval); +}, []); // 空依赖数组,只在组件挂载时执行一次 +``` + +### 2. **创建防重复调用Hook** +创建了 `src/hooks/useApiCall.ts`: + +```typescript +export function usePreventDuplicateCall() { + const isCallingRef = useRef(false); + + const executeOnce = useCallback(async ( + apiFunction: () => Promise + ): Promise => { + if (isCallingRef.current) { + console.log('API call already in progress, skipping...'); + return null; + } + + try { + isCallingRef.current = true; + const result = await apiFunction(); + return result; + } catch (error) { + console.error('API call failed:', error); + return null; + } finally { + isCallingRef.current = false; + } + }, []); + + return { executeOnce }; +} +``` + +### 3. **优化useEffect依赖项** +在PodcastCreator组件中: + +```typescript +// 修复前:多个依赖项可能导致重复调用 +useEffect(() => { + fetchVoices(); +}, [selectedConfig, selectedConfigName]); + +// 修复后:只依赖必要的状态 +useEffect(() => { + if (!selectedConfigName) { + setVoices([]); + return; + } + fetchVoices(); +}, [selectedConfigName]); // 只依赖配置名称 +``` + +### 4. **添加API调用追踪器** +创建了 `src/utils/apiCallTracker.ts` 用于开发环境下监控API调用: + +```typescript +// 自动检测重复调用 +trackCall(url: string, method: string = 'GET'): string { + const recentCalls = this.calls.filter( + c => c.url === url && + c.method === method && + Date.now() - c.timestamp < 5000 + ); + + if (recentCalls.length > 0) { + console.warn(`🚨 检测到重复API调用:`, { + url, method, 重复次数: recentCalls.length + 1 + }); + } +} +``` + +## 修复效果 + +### 修复前: +- 页面加载时 `/api/podcast-status` 被调用2次 +- 配置变化时 `/api/tts-voices` 可能被多次调用 +- 无法监控和调试重复调用问题 + +### 修复后: +- 页面加载时 `/api/podcast-status` 只调用1次 +- 使用防重复调用机制确保同一时间只有一个请求 +- 开发环境下自动检测和警告重复调用 +- 优化了useEffect依赖项,减少不必要的重新执行 + +## 验证方法 + +### 1. **开发环境调试** +打开浏览器开发者工具,在控制台中可以使用: +```javascript +// 查看API调用统计 +window.apiDebug.showStats(); + +// 清空统计数据 +window.apiDebug.clearStats(); +``` + +### 2. **网络面板监控** +在浏览器开发者工具的Network面板中: +- 刷新页面,观察 `/api/podcast-status` 只被调用一次 +- 切换TTS配置,观察 `/api/tts-voices` 不会重复调用 + +### 3. **控制台日志** +开发环境下会自动输出API调用日志: +- `📡 API调用:` - 正常调用 +- `🚨 检测到重复API调用:` - 重复调用警告 + +## 最佳实践建议 + +1. **useEffect合并原则**:相关的副作用应该在同一个useEffect中处理 +2. **依赖项最小化**:只包含真正需要的依赖项 +3. **防重复调用**:对于可能重复的API调用使用防重复机制 +4. **开发调试工具**:在开发环境中添加监控和调试工具 +5. **错误处理**:确保API调用失败时不会影响后续调用 + +## 相关文件 + +- `src/app/page.tsx` - 主页组件修复 +- `src/components/PodcastCreator.tsx` - 播客创建器组件修复 +- `src/components/ConfigSelector.tsx` - 配置选择器组件修复 +- `src/hooks/useApiCall.ts` - 防重复调用Hook(新增) +- `src/utils/apiCallTracker.ts` - API调用追踪器(新增) + +## 注意事项 + +- 修复后的代码保持了原有功能不变 +- 所有修改都向后兼容 +- 调试工具只在开发环境中启用,不会影响生产环境性能 +- 建议在部署前进行充分测试,确保所有功能正常工作 \ No newline at end of file diff --git a/web/src/app/api/audio/[filename]/route.ts b/web/src/app/api/audio/route.ts similarity index 62% rename from web/src/app/api/audio/[filename]/route.ts rename to web/src/app/api/audio/route.ts index d2e6ed6..3c49786 100644 --- a/web/src/app/api/audio/[filename]/route.ts +++ b/web/src/app/api/audio/route.ts @@ -2,32 +2,24 @@ import { NextRequest, NextResponse } from 'next/server'; import path from 'path'; import fs from 'fs'; -export async function GET( - request: NextRequest, - { params }: { params: { filename: string } } -) { +export async function GET(request: NextRequest) { + const filename = request.nextUrl.searchParams.get('filename'); + + // 验证文件名安全性 + if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) { + return NextResponse.json( + { error: '无效的文件名' }, + { status: 400 } + ); + } + + // 构建文件路径 + const outputDir = path.join(process.cwd(), '..', 'output'); + const filePath = path.join(outputDir, filename); + try { - const filename = params.filename; - - // 验证文件名安全性 - if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) { - return NextResponse.json( - { error: '无效的文件名' }, - { status: 400 } - ); - } - - // 构建文件路径 - const outputDir = path.join(process.cwd(), '..', 'output'); - const filePath = path.join(outputDir, filename); - - // 检查文件是否存在 - if (!fs.existsSync(filePath)) { - return NextResponse.json( - { error: '文件不存在' }, - { status: 404 } - ); - } + // 获取文件统计信息,检查文件是否存在 + const stats = fs.statSync(filePath); // 检查文件类型 const ext = path.extname(filename).toLowerCase(); @@ -40,9 +32,6 @@ export async function GET( ); } - // 读取文件 - const fileBuffer = fs.readFileSync(filePath); - // 设置适当的Content-Type let contentType = 'audio/wav'; switch (ext) { @@ -59,12 +48,10 @@ export async function GET( contentType = 'audio/wav'; } - // 获取文件统计信息 - const stats = fs.statSync(filePath); - // 处理Range请求(支持音频流播放) const range = request.headers.get('range'); + // 检查Range请求头是否存在 if (range) { const parts = range.replace(/bytes=/, '').split('-'); const start = parseInt(parts[0], 10); @@ -83,20 +70,26 @@ export async function GET( 'Cache-Control': 'public, max-age=31536000', }, }); + } else { + // 如果没有Range请求,则读取整个文件并返回 + const fileBuffer = fs.readFileSync(filePath); + return new NextResponse(fileBuffer, { + headers: { + 'Content-Type': contentType, + 'Content-Length': stats.size.toString(), + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'public, max-age=31536000', + 'Content-Disposition': `inline; filename="${filename}"`, + }, + }); + } + } catch (error: any) { // 明确指定 error 类型 + if (error.code === 'ENOENT') { + return NextResponse.json( + { error: '文件不存在' }, + { status: 404 } + ); } - - // 返回完整文件 - return new NextResponse(fileBuffer, { - headers: { - 'Content-Type': contentType, - 'Content-Length': stats.size.toString(), - 'Accept-Ranges': 'bytes', - 'Cache-Control': 'public, max-age=31536000', - 'Content-Disposition': `inline; filename="${filename}"`, - }, - }); - - } catch (error) { console.error('Error serving audio file:', error); return NextResponse.json( { error: '服务器内部错误' }, diff --git a/web/src/app/api/auth/[...nextauth]/route.ts b/web/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..6d24617 --- /dev/null +++ b/web/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,19 @@ +import NextAuth from 'next-auth'; +import GoogleProvider from 'next-auth/providers/google'; +import GitHubProvider from 'next-auth/providers/github'; + +const handler = NextAuth({ + providers: [ + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + }), + GitHubProvider({ + clientId: process.env.GITHUB_ID!, + clientSecret: process.env.GITHUB_SECRET!, + }), + ], + secret: process.env.NEXTAUTH_SECRET, +}); + +export { handler as GET, handler as POST }; \ No newline at end of file diff --git a/web/src/app/api/config/route.ts b/web/src/app/api/config/route.ts index d9073c8..1644ce4 100644 --- a/web/src/app/api/config/route.ts +++ b/web/src/app/api/config/route.ts @@ -3,6 +3,15 @@ import path from 'path'; import fs from 'fs/promises'; import type { TTSConfig } from '@/types'; +const TTS_PROVIDER_ORDER = [ + 'edge-tts', + 'doubao-tts', + 'minimax', + 'fish-audio', + 'gemini-tts', + 'index-tts', +]; + // 获取配置文件列表 export async function GET() { try { @@ -17,6 +26,21 @@ export async function GET() { path: file, })); + // 根据预定义顺序排序 + configFiles.sort((a, b) => { + const aName = a.name.replace('.json', ''); + const bName = b.name.replace('.json', ''); + const aIndex = TTS_PROVIDER_ORDER.indexOf(aName); + const bIndex = TTS_PROVIDER_ORDER.indexOf(bName); + + // 未知提供商排在已知提供商之后,并保持其相对顺序 + if (aIndex === -1 && bIndex === -1) return 0; + if (aIndex === -1) return 1; + if (bIndex === -1) return -1; + + return aIndex - bIndex; + }); + return NextResponse.json({ success: true, data: configFiles, diff --git a/web/src/app/api/generate-podcast/route.ts b/web/src/app/api/generate-podcast/route.ts index 8b121da..238475a 100644 --- a/web/src/app/api/generate-podcast/route.ts +++ b/web/src/app/api/generate-podcast/route.ts @@ -1,235 +1,30 @@ import { NextRequest, NextResponse } from 'next/server'; -import { spawn } from 'child_process'; -import path from 'path'; -import fs from 'fs/promises'; -import { generateId } from '@/lib/utils'; -import type { PodcastGenerationRequest, PodcastGenerationResponse } from '@/types'; - -// 存储生成任务的状态 -const generationTasks = new Map(); +import { startPodcastGenerationTask } from '@/lib/podcastApi'; +import type { PodcastGenerationRequest } from '@/types'; export async function POST(request: NextRequest) { try { const body: PodcastGenerationRequest = await request.json(); - - // 验证请求数据 - if (!body.topic || !body.topic.trim()) { + const result = await startPodcastGenerationTask(body); + + if (result.success) { + return NextResponse.json({ + success: true, + data: result.data, + }); + } else { return NextResponse.json( - { success: false, error: '请提供播客主题' }, - { status: 400 } + { success: false, error: result.error }, + { status: result.statusCode || 400 } // Use 400 for client-side errors, or 500 for internal server errors ); } - // 生成任务ID - const taskId = generateId(); - - // 初始化任务状态 - const task: PodcastGenerationResponse = { - id: taskId, - status: 'pending', - progress: 0, - createdAt: new Date().toISOString(), - estimatedTime: getEstimatedTime(body.duration), - }; - - generationTasks.set(taskId, task); - - // 异步启动Python脚本 - startPodcastGeneration(taskId, body); - - return NextResponse.json({ - success: true, - data: task, - }); - - } catch (error) { + } catch (error: any) { console.error('Error in generate-podcast API:', error); + const statusCode = error.statusCode || 500; // 假设 HttpError 会有 statusCode 属性 return NextResponse.json( - { success: false, error: '服务器内部错误' }, - { status: 500 } + { success: false, error: error.message || '服务器内部错误' }, + { status: statusCode } ); } } - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const taskId = searchParams.get('id'); - - if (!taskId) { - return NextResponse.json( - { success: false, error: '缺少任务ID' }, - { status: 400 } - ); - } - - const task = generationTasks.get(taskId); - if (!task) { - return NextResponse.json( - { success: false, error: '任务不存在' }, - { status: 404 } - ); - } - - return NextResponse.json({ - success: true, - data: task, - }); -} - -async function startPodcastGeneration(taskId: string, request: PodcastGenerationRequest) { - try { - // 更新任务状态 - updateTaskStatus(taskId, 'generating_outline', 10); - - // 准备输入文件 - const inputContent = prepareInputContent(request); - const inputFilePath = path.join(process.cwd(), '..', 'input.txt'); - await fs.writeFile(inputFilePath, inputContent, 'utf-8'); - - // 如果有TTS配置,写入配置文件 - let configPath = ''; - if (request.ttsConfig) { - // 查找匹配的配置文件 - const configDir = path.join(process.cwd(), '..', 'config'); - const configFiles = await fs.readdir(configDir); - const matchingConfig = configFiles.find(file => - file.endsWith('.json') && - !file.includes('tts_providers') && - file.includes(request.ttsConfig?.tts_provider || '') - ); - - if (matchingConfig) { - configPath = path.join(configDir, matchingConfig); - } - } - - // 构建Python命令参数 - const pythonScriptPath = path.join(process.cwd(), '..', 'podcast_generator.py'); - const args = [ - pythonScriptPath, - '--threads', '2', // 使用2个线程加速生成 - ]; - - // 如果有环境变量中的API配置,添加到参数中 - if (process.env.OPENAI_API_KEY) { - args.push('--api-key', process.env.OPENAI_API_KEY); - } - if (process.env.OPENAI_BASE_URL) { - args.push('--base-url', process.env.OPENAI_BASE_URL); - } - if (process.env.OPENAI_MODEL) { - args.push('--model', process.env.OPENAI_MODEL); - } - - // 启动Python进程 - const pythonProcess = spawn('python', args, { - cwd: path.join(process.cwd(), '..'), - stdio: ['pipe', 'pipe', 'pipe'], - }); - - let outputBuffer = ''; - let errorBuffer = ''; - - pythonProcess.stdout.on('data', (data) => { - outputBuffer += data.toString(); - // 解析输出来更新进度 - parseProgressFromOutput(taskId, outputBuffer); - }); - - pythonProcess.stderr.on('data', (data) => { - errorBuffer += data.toString(); - console.error('Python stderr:', data.toString()); - }); - - pythonProcess.on('close', async (code) => { - if (code === 0) { - // 生成成功,查找输出文件 - try { - const outputDir = path.join(process.cwd(), '..', 'output'); - const files = await fs.readdir(outputDir); - const audioFile = files.find(file => file.endsWith('.wav')); - - if (audioFile) { - const audioUrl = `/api/audio/${audioFile}`; - updateTaskStatus(taskId, 'completed', 100, undefined, audioUrl); - } else { - updateTaskStatus(taskId, 'error', 100, '未找到生成的音频文件'); - } - } catch (error) { - updateTaskStatus(taskId, 'error', 100, '处理输出文件时出错'); - } - } else { - updateTaskStatus(taskId, 'error', 100, `生成失败: ${errorBuffer}`); - } - }); - - pythonProcess.on('error', (error) => { - console.error('Python process error:', error); - updateTaskStatus(taskId, 'error', 100, `进程启动失败: ${error.message}`); - }); - - } catch (error) { - console.error('Error starting podcast generation:', error); - updateTaskStatus(taskId, 'error', 100, `启动生成任务失败: ${error}`); - } -} - -function updateTaskStatus( - taskId: string, - status: PodcastGenerationResponse['status'], - progress: number, - error?: string, - audioUrl?: string -) { - const task = generationTasks.get(taskId); - if (task) { - task.status = status; - task.progress = progress; - if (error) task.error = error; - if (audioUrl) task.audioUrl = audioUrl; - generationTasks.set(taskId, task); - } -} - -function parseProgressFromOutput(taskId: string, output: string) { - // 根据Python脚本的输出解析进度 - // 这里需要根据实际的Python脚本输出格式来调整 - - if (output.includes('生成播客大纲')) { - updateTaskStatus(taskId, 'generating_outline', 20); - } else if (output.includes('生成播客脚本')) { - updateTaskStatus(taskId, 'generating_script', 40); - } else if (output.includes('生成音频')) { - updateTaskStatus(taskId, 'generating_audio', 60); - } else if (output.includes('合并音频')) { - updateTaskStatus(taskId, 'merging', 80); - } -} - -function prepareInputContent(request: PodcastGenerationRequest): string { - let content = request.topic; - - if (request.customInstructions) { - content += '\n\n```custom-begin\n' + request.customInstructions + '\n```custom-end'; - } - - // 添加其他配置信息 - content += '\n\n```config\n'; - content += `语言: ${request.language || 'zh-CN'}\n`; - content += `风格: ${request.style || 'casual'}\n`; - content += `时长: ${request.duration || 'medium'}\n`; - content += `说话人数量: ${request.speakers || 2}\n`; - content += '```'; - - return content; -} - -function getEstimatedTime(duration?: string): number { - // 根据时长估算生成时间(秒) - switch (duration) { - case 'short': return 120; // 2分钟 - case 'medium': return 300; // 5分钟 - case 'long': return 600; // 10分钟 - default: return 300; - } -} \ No newline at end of file diff --git a/web/src/app/api/podcast-status/route.ts b/web/src/app/api/podcast-status/route.ts new file mode 100644 index 0000000..c74e3f4 --- /dev/null +++ b/web/src/app/api/podcast-status/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getPodcastStatus } from '@/lib/podcastApi'; + +export const revalidate = 0; // 等同于 `cache: 'no-store'` + +export async function GET(request: NextRequest) { + const result = await getPodcastStatus(); + if (result.success) { + return NextResponse.json({ + success: true, + ...result.data, // 展开 result.data,因为它已经是 PodcastStatusResponse 类型 + }); + } else { + return NextResponse.json( + { success: false, error: result.error || '获取任务状态失败' }, + { status: result.statusCode || 500 } + ); + } +} \ No newline at end of file diff --git a/web/src/app/api/tts-providers/route.ts b/web/src/app/api/tts-providers/route.ts new file mode 100644 index 0000000..6a51995 --- /dev/null +++ b/web/src/app/api/tts-providers/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server'; +import path from 'path'; +import fs from 'fs/promises'; + +// 获取 tts_providers.json 文件内容 +export async function GET() { + try { + const configPath = path.join(process.cwd(), '..', 'config', 'tts_providers.json'); + const configContent = await fs.readFile(configPath, 'utf-8'); + const config = JSON.parse(configContent); + + return NextResponse.json({ + success: true, + data: config, + }); + } catch (error) { + console.error('Error reading tts_providers.json:', error); + return NextResponse.json( + { success: false, error: '无法读取TTS提供商配置文件' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/web/src/app/api/tts-voices/route.ts b/web/src/app/api/tts-voices/route.ts deleted file mode 100644 index 35031c7..0000000 --- a/web/src/app/api/tts-voices/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import path from 'path'; -import { promises as fs } from 'fs'; - -export async function POST(request: NextRequest) { - try { - const { ttsConfigName } = await request.json(); - - if (!ttsConfigName) { - return NextResponse.json( - { success: false, error: '缺少 ttsConfigName 参数' }, - { status: 400 } - ); - } - - const configPath = path.join(process.cwd(), '..', 'config', ttsConfigName); - const configContent = await fs.readFile(configPath, 'utf-8'); - const ttsConfig = JSON.parse(configContent); - - // 假设 ttsConfig 结构中有一个 `voices` 字段 - // 如果没有,可能需要根据 ttsConfig 的 provider 调用不同的逻辑来获取声音列表 - if (ttsConfig && ttsConfig.voices) { - // 模拟添加 sample_audio_url - const voicesWithSampleAudio = ttsConfig.voices.map((voice: any) => ({ - ...voice, - sample_audio_url: `${voice.audio}`, // 假设有一个示例音频路径 - })); - return NextResponse.json({ - success: true, - data: voicesWithSampleAudio, - }); - } else { - return NextResponse.json( - { success: false, error: '未找到声音配置' }, - { status: 404 } - ); - } - } catch (error) { - console.error('Error fetching TTS voices:', error); - return NextResponse.json( - { success: false, error: '无法获取TTS声音列表' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 21481d4..6957b39 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; +import AuthProviders from '@/components/AuthProviders'; const inter = Inter({ subsets: ['latin'], @@ -39,13 +40,15 @@ export default function RootLayout({ -
- {children} -
- {/* Toast容器 */} -
- {/* Modal容器 */} - diff --git a/web/src/components/LoginModal.tsx b/web/src/components/LoginModal.tsx new file mode 100644 index 0000000..7a76720 --- /dev/null +++ b/web/src/components/LoginModal.tsx @@ -0,0 +1,97 @@ +// web/src/components/LoginModal.tsx +"use client"; // 标记为客户端组件,因为需要交互性 + +import React, { FC, MouseEventHandler, useCallback, useRef } from "react"; +import { signIn } from "next-auth/react"; +import { createPortal } from "react-dom"; +import { XMarkIcon } from "@heroicons/react/24/outline"; // 导入关闭图标 +import { Chrome, Github } from "lucide-react"; // 从 lucide-react 导入 Google 和 GitHub 图标 + +interface LoginModalProps { + isOpen: boolean; + onClose: () => void; +} + +const LoginModal: FC = ({ isOpen, onClose }) => { + const modalRef = useRef(null); + + // 点击背景关闭模态框 + const handleOverlayClick: MouseEventHandler = useCallback( + (e) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose(); + } + }, + [onClose] + ); + + if (!isOpen) return null; + + // 使用 React Portal 将模态框渲染到 body 下,避免Z-index问题和父组件样式影响 + return createPortal( +
+
+ + +

+ 登录您的账户 +

+ +
+ + + +
+
+
, + document.body // 渲染到 body 元素下 + ); +}; + +export default LoginModal; + +// 添加一个简单的 Tailwind CSS 动画到你的 web/tailwind.config.js 文件中 +// 示例: +// module.exports = { +// theme: { +// extend: { +// keyframes: { +// 'scale-in': { +// '0%': { transform: 'scale(0.95)', opacity: '0' }, +// '100%': { transform: 'scale(1)', opacity: '1' }, +// } +// }, +// animation: { +// 'scale-in': 'scale-in 0.2s ease-out', +// } +// } +// } +// } \ No newline at end of file diff --git a/web/src/components/PodcastCard.tsx b/web/src/components/PodcastCard.tsx index 0b945d8..12effe8 100644 --- a/web/src/components/PodcastCard.tsx +++ b/web/src/components/PodcastCard.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import Image from 'next/image'; -import { Play, Clock, Eye, User, Heart, MoreHorizontal } from 'lucide-react'; +import { Play, Pause, Clock, Eye, User, Heart, MoreHorizontal } from 'lucide-react'; import { cn, formatTime, formatRelativeTime } from '@/lib/utils'; import type { PodcastItem } from '@/types'; @@ -11,17 +11,23 @@ interface PodcastCardProps { onPlay?: (podcast: PodcastItem) => void; className?: string; variant?: 'default' | 'compact'; + currentPodcast?: PodcastItem | null; + isPlaying?: boolean; } -const PodcastCard: React.FC = ({ - podcast, +const PodcastCard: React.FC = ({ + podcast, onPlay, className, - variant = 'default' + variant = 'default', + currentPodcast, + isPlaying, }) => { const [isLiked, setIsLiked] = useState(false); const [isHovered, setIsHovered] = useState(false); + const isCurrentlyPlaying = currentPodcast?.id === podcast.id && isPlaying; + const handlePlayClick = (e: React.MouseEvent) => { e.stopPropagation(); onPlay?.(podcast); @@ -42,15 +48,15 @@ const PodcastCard: React.FC = ({ if (variant === 'compact') { return (
-
+
{/* 缩略图 */} -
+
{podcast.thumbnail ? ( = ({ onClick={handlePlayClick} className="w-8 h-8 bg-white/90 hover:bg-white rounded-full flex items-center justify-center transform scale-90 hover:scale-100 transition-all duration-200" > - + {isCurrentlyPlaying ? ( + + ) : ( + + )}
{/* 内容 */}
-

+

{podcast.title}

-

- {podcast.author.name} +

+ {podcast.description}

{formatTime(podcast.duration)} - + {/* {podcast.playCount.toLocaleString()} - + */}
+ {/* 遮罩层 */} + {(podcast.status === 'pending' || podcast.status === 'running') && ( +
+

+ {podcast.status === 'pending' ? '播客生成排队中...' : '播客生成中...'} +

+
+ )}
); } @@ -105,7 +123,7 @@ const PodcastCard: React.FC = ({ return (
= ({ onClick={handlePlayClick} className="w-14 h-14 bg-white/95 hover:bg-white rounded-full flex items-center justify-center transform scale-90 hover:scale-100 transition-all duration-300 shadow-medium" > - + {isCurrentlyPlaying ? ( + + ) : ( + + )}
@@ -151,8 +173,8 @@ const PodcastCard: React.FC = ({ onClick={handleLikeClick} className={cn( "w-8 h-8 rounded-full flex items-center justify-center transition-all duration-200 backdrop-blur-sm", - isLiked - ? "bg-red-500 text-white" + isLiked + ? "bg-red-500 text-white" : "bg-white/90 hover:bg-white text-neutral-600 hover:text-red-500" )} > @@ -206,10 +228,10 @@ const PodcastCard: React.FC = ({ {/* 元数据 */}
-
+ {/*
{podcast.playCount.toLocaleString()} -
+
*/}
{formatRelativeTime(podcast.createdAt)}
diff --git a/web/src/components/PodcastCreator.tsx b/web/src/components/PodcastCreator.tsx index 170a533..fe84824 100644 --- a/web/src/components/PodcastCreator.tsx +++ b/web/src/components/PodcastCreator.tsx @@ -1,11 +1,11 @@ 'use client'; import React, { useState, useRef, useEffect } from 'react'; -import { - Play, - Wand2, - Link, - Copy, +import { + Play, + Wand2, + Link, + Copy, Upload, Globe, ChevronDown, @@ -14,47 +14,89 @@ import { import { cn } from '@/lib/utils'; import ConfigSelector from './ConfigSelector'; import VoicesModal from './VoicesModal'; // 引入 VoicesModal -import type { PodcastGenerationRequest, TTSConfig, Voice } from '@/types'; +import { useToast, ToastContainer } from './Toast'; // 引入 Toast Hook 和 Container +import { setItem, getItem } from '@/lib/storage'; // 引入 localStorage 工具 +import type { PodcastGenerationRequest, TTSConfig, Voice, SettingsFormData } from '@/types'; interface PodcastCreatorProps { onGenerate: (request: PodcastGenerationRequest) => void; isGenerating?: boolean; - credits: number; // 新增积分属性 + credits: number; + settings: SettingsFormData | null; // 新增 settings 属性 } const PodcastCreator: React.FC = ({ onGenerate, isGenerating = false, - credits // 解构 credits 属性 + credits, + settings // 解构 settings 属性 }) => { + + const languageOptions = [ + { value: 'Make sure the language of the output content is Chinese', label: '简体中文' }, + { value: 'Make sure the language of the output content is English', label: 'English' }, + { value: 'Make sure the language of the output content is Japanese', label: '日本語' }, + ]; + + const durationOptions = [ + { value: '5-10 minutes', label: '5-10分钟' }, + { value: '15-20 minutes', label: '15-20分钟' }, + { value: '25-30 minutes', label: '25-30分钟' }, + ]; + const [topic, setTopic] = useState(''); const [customInstructions, setCustomInstructions] = useState(''); const [selectedMode, setSelectedMode] = useState<'ai-podcast' | 'flowspeech'>('ai-podcast'); - const [language, setLanguage] = useState('zh-CN'); + const [language, setLanguage] = useState(languageOptions[0].value); + const [duration, setDuration] = useState(durationOptions[0].value); const [showVoicesModal, setShowVoicesModal] = useState(false); // 新增状态 - const [voices, setVoices] = useState([]); // 新增 voices 状态 - const [selectedPodcastVoices, setSelectedPodcastVoices] = useState<{[key: string]: Voice[]}>({}); // 新增:单独存储选中的说话人 - const [style, setStyle] = useState<'casual' | 'professional' | 'educational' | 'entertaining'>('casual'); - const [duration, setDuration] = useState<'short' | 'medium' | 'long'>('medium'); + const [voices, setVoices] = useState([]); // 从 ConfigSelector 获取 voices + const [selectedPodcastVoices, setSelectedPodcastVoices] = useState<{[key: string]: Voice[]}>(() => { + // 从 localStorage 读取缓存的说话人配置 + const cachedVoices = getItem<{[key: string]: Voice[]}>('podcast-selected-voices'); + return cachedVoices || {}; + }); // 新增:单独存储选中的说话人 const [selectedConfig, setSelectedConfig] = useState(null); const [selectedConfigName, setSelectedConfigName] = useState(''); // 新增状态来存储配置文件的名称 const fileInputRef = useRef(null); - + + const { toasts, error } = useToast(); // 使用 useToast hook + const handleSubmit = () => { - if (!topic.trim()) return; + if (!topic.trim()) { + error("主题不能为空", "请输入播客主题。"); // 使用 toast.error + return; + } + if (!selectedConfig) { + error("TTS配置未选择", "请选择一个TTS配置。"); // 使用 toast.error + return; + } - const request: PodcastGenerationRequest = { - topic: topic.trim(), - customInstructions: customInstructions.trim() || undefined, - speakers: selectedPodcastVoices[selectedConfigName]?.length || selectedConfig?.podUsers?.length || 2, // 优先使用选中的说话人数量 - language, - style, - duration, - ttsConfig: selectedConfig ? { ...selectedConfig, voices: selectedPodcastVoices[selectedConfigName] || [] } : undefined, // 将选中的说话人添加到 ttsConfig - }; - - onGenerate(request); - }; + if (!selectedPodcastVoices[selectedConfigName] || selectedPodcastVoices[selectedConfigName].length === 0) { + error("请选择说话人", "请至少选择一位播客说话人。"); // 使用 toast.error + return; + } + + let inputTxtContent = topic.trim(); + if (customInstructions.trim()) { + inputTxtContent = "```custom-begin"+`\n${customInstructions.trim()}\n`+"```custom-end"+`\n${inputTxtContent}`; + } + + const request: PodcastGenerationRequest = { + tts_provider: selectedConfigName.replace('.json', ''), + input_txt_content: inputTxtContent, + tts_providers_config_content: JSON.stringify(settings), + podUsers_json_content: JSON.stringify(selectedPodcastVoices[selectedConfigName] || []), + api_key: settings?.apikey, + base_url: settings?.baseurl, + model: settings?.model, + callback_url: "https://your-callback-url.com/podcast-status", // Assuming a fixed callback URL + usetime: duration, + output_language: language, + }; + + onGenerate(request); +}; const handleFileUpload = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; @@ -77,144 +119,103 @@ const PodcastCreator: React.FC = ({ } }; - const languageOptions = [ - { value: 'zh-CN', label: '简体中文' }, - { value: 'en-US', label: 'English' }, - { value: 'ja-JP', label: '日本語' }, - ]; - - const durationOptions = [ - { value: 'short', label: '5-10分钟' }, - { value: 'medium', label: '15-20分钟' }, - { value: 'long', label: '25-30分钟' }, - ]; - - useEffect(() => { - const fetchVoices = async () => { - if (selectedConfig && selectedConfigName) { // 确保 selectedConfigName 存在 - try { - const response = await fetch('/api/tts-voices', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ ttsConfigName: selectedConfigName }), // 使用 selectedConfigName - }); - const data = await response.json(); - if (data.success) { - setVoices(data.data); - } else { - console.error('Failed to fetch voices:', data.error); - setVoices([]); - } - } catch (error) { - console.error('Error fetching voices:', error); - setVoices([]); - } - } else { - setVoices([]); - } - }; - - fetchVoices(); - }, [selectedConfig, selectedConfigName]); // 依赖项中添加 selectedConfigName - return ( -
- {/* 品牌标题区域 */} -
-
-
-
-
-

PodcastHub

-
-

- 把你的创意转为播客 -

- - {/* 模式切换按钮 */} -
- - -
-
- - {/* 主要创作区域 */} -
- {/* 输入区域 */} -
-