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容器 */}
-
+
+
+ {children}
+
+ {/* Toast容器 */}
+
+ {/* Modal容器 */}
+
+