Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 73 additions & 7 deletions backend/api/routes/podcast.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

Handles podcast generation with controllable duration.
"""
from typing import Callable

from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
import os
Expand All @@ -14,26 +16,40 @@
PodcastScriptResponse
)
from ...config import get_settings, Settings
from ...dependencies import get_vector_store, get_llm_provider, get_audio_speech_provider, get_source_service
from ...dependencies import (
get_audio_speech_provider,
get_knowledge_repository,
get_llm_provider,
get_source_service,
get_vector_store,
)
from ...core.services.podcast_service import PodcastService
from ...core.interfaces.vector_store import VectorStoreInterface
from ...core.services.source_service import SourceService
from ...core.interfaces.knowledge_repository import KnowledgeRepositoryInterface
from ...domain.source import Artifact, ArtifactType, JobStatus

router = APIRouter(prefix="/podcast", tags=["Podcast"])


def get_podcast_llm_factory() -> Callable:
return get_llm_provider


@router.post("/generate", response_model=PodcastGenerateResponse)
async def generate_podcast(
request: PodcastGenerateRequest,
settings: Settings = Depends(get_settings),
vector_store: VectorStoreInterface = Depends(get_vector_store),
source_service: SourceService = Depends(get_source_service),
repository: KnowledgeRepositoryInterface = Depends(get_knowledge_repository),
llm_factory: Callable = Depends(get_podcast_llm_factory),
tts = Depends(get_audio_speech_provider),
):
"""生成播客(脚本 + 音频)"""
try:
# 获取 LLM 提供商
llm = get_llm_provider(
llm = llm_factory(
provider=request.llm_provider,
api_key=request.llm_api_key,
base_url=request.llm_base_url,
Expand Down Expand Up @@ -80,14 +96,63 @@ async def generate_podcast(
else:
raise HTTPException(
status_code=400,
detail="请提供 source_text 或 document_ids"
detail="请提供 source_text、source_ids 或 document_ids"
)

audio_url = f"/api/podcast/download/{result['audio_filename']}" if result.get("audio_filename") else None
transcript_url = f"/api/podcast/download/{result['transcript_filename']}"
script = result["script"]
turns = [{"speaker": turn.speaker, "text": turn.text} for turn in script.dialogues]
speakers = list(dict.fromkeys([script.host_name, script.guest_name]))
artifact = Artifact(
artifact_type=ArtifactType.PODCAST_SCRIPT,
title=getattr(script, "title", None) or "Podcast Script",
source_ids=request.source_ids or request.document_ids,
markdown=result["transcript"],
payload={
"title": getattr(script, "title", "Podcast Script"),
"speakers": speakers,
"turns": turns,
"estimated_duration_minutes": result["duration_minutes"],
"transcript": result["transcript"],
"duration_minutes": result["duration_minutes"],
"dialogue_count": result["dialogue_count"],
"audio_url": audio_url,
"audio_status": result.get("audio_status", {}),
"audio_filename": result.get("audio_filename"),
"transcript_url": transcript_url,
"transcript_filename": result.get("transcript_filename"),
},
file_refs=[
{
"format": "markdown",
"mime_type": "text/markdown",
"name": result.get("transcript_filename"),
"url": transcript_url,
}
],
status=JobStatus.SUCCEEDED,
)
if audio_url:
artifact.file_refs.append(
{
"format": "mp3",
"mime_type": "audio/mpeg",
"name": result.get("audio_filename"),
"url": audio_url,
}
)
artifact = await repository.save_artifact(artifact)

return PodcastGenerateResponse(
audio_url=f"/api/podcast/download/{result['audio_filename']}" if result.get("audio_filename") else None,
artifact_id=artifact.id,
title=artifact.title,
speakers=speakers,
turns=turns,
audio_url=audio_url,
audio_status=result.get("audio_status", {}),
audio_filename=result.get("audio_filename"),
transcript_url=f"/api/podcast/download/{result['transcript_filename']}",
transcript_url=transcript_url,
transcript_filename=result.get("transcript_filename"),
transcript=result["transcript"],
duration_minutes=result["duration_minutes"],
Expand All @@ -103,10 +168,11 @@ async def generate_script_only(
request: PodcastScriptRequest,
vector_store: VectorStoreInterface = Depends(get_vector_store),
source_service: SourceService = Depends(get_source_service),
llm_factory: Callable = Depends(get_podcast_llm_factory),
):
"""仅生成播客脚本(不合成音频)"""
try:
llm = get_llm_provider(
llm = llm_factory(
provider=request.llm_provider,
api_key=request.llm_api_key,
base_url=request.llm_base_url,
Expand Down Expand Up @@ -135,7 +201,7 @@ async def generate_script_only(
all_text.append(text)
source_text = "\n\n---\n\n".join(all_text)
else:
raise HTTPException(status_code=400, detail="请提供文本或文档")
raise HTTPException(status_code=400, detail="请提供 source_text、source_ids 或 document_ids")

# 生成脚本
script = await podcast_service.generate_script_only(
Expand Down
4 changes: 4 additions & 0 deletions backend/api/schemas/podcast.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class PodcastGenerateRequest(BaseModel):

class PodcastGenerateResponse(BaseModel):
"""播客生成响应"""
artifact_id: Optional[str] = Field(None, description="持久化的播客 artifact ID")
title: str = Field(default="Podcast Script", description="播客标题")
speakers: List[str] = Field(default_factory=list, description="播客说话人")
turns: List[Dict[str, str]] = Field(default_factory=list, description="结构化对话轮次")
audio_url: Optional[str] = Field(None, description="音频下载URL")
audio_status: Dict = Field(default_factory=dict, description="音频生成状态")
audio_filename: Optional[str] = None
Expand Down
7 changes: 7 additions & 0 deletions backend/domain/artifact_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ class PodcastScriptArtifactPayload(BaseModel):
turns: list[dict[str, str]]
estimated_duration_minutes: float
transcript: str
duration_minutes: float | None = None
dialogue_count: int | None = None
audio_url: str | None = None
audio_status: dict[str, Any] = Field(default_factory=dict)
audio_filename: str | None = None
transcript_url: str | None = None
transcript_filename: str | None = None


class PPTOutlineArtifactPayload(BaseModel):
Expand Down
18 changes: 18 additions & 0 deletions frontend/e2e/interactive-artifacts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ const artifactPayloads: Record<string, Record<string, unknown>> = {
],
key_takeaways: ['优先使用 HTTPS', '证书链需要可信']
},
faq: {
title: 'HTTPS FAQ',
items: [
{ question: 'HTTPS 解决什么问题?', answer: '它通过 TLS 降低窃听、篡改和身份冒充风险。' },
{ question: '证书为什么重要?', answer: '证书把服务器身份和公钥绑定起来。' }
]
},
data_table: {
title: '协议对比表',
columns: ['协议', '安全性', '用途'],
Expand Down Expand Up @@ -205,11 +212,22 @@ test('renders NotebookLM-like interactive Studio artifacts', async ({ page }) =>
await expect(page.getByRole('heading', { name: '安全属性' })).toBeVisible();
await expect(page.getByText('证书链需要可信')).toBeVisible();

await page.getByRole('button', { name: /^FAQ$/ }).click();
await page.getByRole('button', { name: '生成', exact: true }).click();
await page.getByRole('button', { name: /HTTPS FAQ/ }).click();
await expect(page.getByRole('button', { name: 'HTTPS 解决什么问题?' })).toBeVisible();
await expect(page.getByText('它通过 TLS 降低窃听、篡改和身份冒充风险。')).not.toBeVisible();
await page.getByRole('button', { name: 'HTTPS 解决什么问题?' }).click();
await expect(page.getByText('它通过 TLS 降低窃听、篡改和身份冒充风险。')).toBeVisible();

await page.getByRole('button', { name: /表格/ }).click();
await page.getByRole('button', { name: '生成', exact: true }).click();
await page.getByRole('button', { name: /协议对比表/ }).click();
await expect(page.getByRole('columnheader', { name: '协议' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'TLS 加密' })).toBeVisible();
const tableRegion = page.getByRole('region', { name: '协议对比表' });
await expect(tableRegion).toBeVisible();
await expect.poll(async () => tableRegion.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true);

let developmentMessage = '';
page.once('dialog', async dialog => {
Expand Down
77 changes: 70 additions & 7 deletions frontend/e2e/non-ppt-workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const source = {
test('non-PPT workflow covers source upload, RAG chat, studio artifacts, and podcast script', async ({ page }) => {
let sources: typeof source[] = [];
let notes: Array<Record<string, unknown>> = [];
let artifacts: Array<Record<string, unknown>> = [];
let artifactCount = 0;

await page.route('**/api/config', route => route.fulfill({
Expand Down Expand Up @@ -74,6 +75,11 @@ test('non-PPT workflow covers source upload, RAG chat, studio artifacts, and pod
body: JSON.stringify(notes[0])
});
});
await page.route('**/api/artifacts', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ artifacts, total: artifacts.length })
}));
await page.route('**/api/artifacts/generate', async route => {
const request = route.request();
const payload = request.postDataJSON() as { artifact_type: string };
Expand All @@ -92,13 +98,62 @@ test('non-PPT workflow covers source upload, RAG chat, studio artifacts, and pod
})
});
});
await page.route('**/api/podcast/generate', route => route.fulfill({
await page.route('**/api/podcast/generate', route => {
const podcastArtifact = {
id: 'podcast-artifact-1',
artifact_type: 'podcast_script',
title: '播客脚本',
source_ids: [source.id],
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
status: 'succeeded',
markdown: '# 播客脚本\n\n1. 开场\n2. 深入讨论',
payload: {
title: '播客脚本',
speakers: ['主持人', '嘉宾'],
turns: [
{ speaker: '主持人', text: '开场' },
{ speaker: '嘉宾', text: '深入讨论' }
],
estimated_duration_minutes: 20,
duration_minutes: 20,
dialogue_count: 2,
transcript: '# 播客脚本\n\n1. 开场\n2. 深入讨论',
audio_url: '/api/podcast/download/demo.mp3',
audio_filename: 'demo.mp3',
transcript_url: '/api/podcast/download/demo.md',
transcript_filename: 'demo.md',
audio_status: { status: 'succeeded' }
},
file_refs: [
{ format: 'markdown', mime_type: 'text/markdown', name: 'demo.md', url: '/api/podcast/download/demo.md' },
{ format: 'mp3', mime_type: 'audio/mpeg', name: 'demo.mp3', url: '/api/podcast/download/demo.mp3' }
]
};
artifacts = [podcastArtifact, ...artifacts];
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
artifact_id: podcastArtifact.id,
title: podcastArtifact.title,
speakers: podcastArtifact.payload.speakers,
turns: podcastArtifact.payload.turns,
transcript: podcastArtifact.payload.transcript,
audio_url: podcastArtifact.payload.audio_url,
audio_filename: podcastArtifact.payload.audio_filename,
transcript_url: podcastArtifact.payload.transcript_url,
transcript_filename: podcastArtifact.payload.transcript_filename,
audio_status: podcastArtifact.payload.audio_status,
duration_minutes: podcastArtifact.payload.duration_minutes,
dialogue_count: podcastArtifact.payload.dialogue_count
})
});
});
await page.route('**/api/podcast/download/*', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
transcript: '# 播客脚本\n\n1. 开场\n2. 深入讨论',
audio_url: null
})
contentType: route.request().url().endsWith('.mp3') ? 'audio/mpeg' : 'text/markdown',
body: route.request().url().endsWith('.mp3') ? Buffer.from('mp3') : '# 播客脚本'
}));

await page.goto('/');
Expand Down Expand Up @@ -136,7 +191,15 @@ test('non-PPT workflow covers source upload, RAG chat, studio artifacts, and pod
await page.getByRole('button', { name: '播客' }).click();
await page.getByRole('button', { name: '20-30' }).click();
await page.getByRole('button', { name: '生成播客' }).click();
await page.getByRole('button', { name: /播客脚本 podcast/ }).click();
await page.getByRole('button', { name: /播客脚本/ }).click();
await expect(page.getByRole('heading', { name: '播客脚本' })).toBeVisible();
await expect(page.getByRole('listitem').filter({ hasText: '深入讨论' })).toBeVisible();
await expect(page.getByRole('link', { name: /MP3/ })).toHaveAttribute('href', /demo\.mp3/);
await expect(page.getByRole('link', { name: /转录文本/ })).toHaveAttribute('href', /demo\.md/);

await page.reload();
await page.getByRole('button', { name: /播客脚本/ }).click();
await expect(page.getByRole('heading', { name: '播客脚本' })).toBeVisible();
await expect(page.getByRole('link', { name: /MP3/ })).toHaveAttribute('href', /demo\.mp3/);
await expect(page.getByRole('link', { name: /转录文本/ })).toHaveAttribute('href', /demo\.md/);
});
10 changes: 10 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
import { SlideDeckWorkspace } from './components/SlideDeckWorkspace';

export type Language = 'zh' | 'en';
export const LanguageContext = createContext<{ lang: Language; setLang: (l: Language) => void }>({

Check warning on line 11 in frontend/src/App.tsx

View workflow job for this annotation

GitHub Actions / Frontend tests and build

Fast refresh only works when a file only exports components. Move your React context(s) to a separate file
lang: 'zh',
setLang: () => { }
});
export const useLanguage = () => useContext(LanguageContext);

Check warning on line 15 in frontend/src/App.tsx

View workflow job for this annotation

GitHub Actions / Frontend tests and build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

export const translations = {

Check warning on line 17 in frontend/src/App.tsx

View workflow job for this annotation

GitHub Actions / Frontend tests and build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
zh: {
sources: '来源',
chat: '对话',
Expand Down Expand Up @@ -130,9 +130,13 @@
title: string;
createdAt: Date;
audioUrl?: string | null;
transcriptUrl?: string | null;
audioFilename?: string | null;
transcriptFilename?: string | null;
transcript?: string;
markdown?: string;
payload?: Record<string, unknown>;
fileRefs?: Array<Record<string, unknown>>;
downloadJsonUrl?: string;
downloadMarkdownUrl?: string;
downloadSvgUrl?: string;
Expand Down Expand Up @@ -198,13 +202,13 @@
refreshSources();
refreshArtifacts();
loadRuntimeConfig();
}, []);

Check warning on line 205 in frontend/src/App.tsx

View workflow job for this annotation

GitHub Actions / Frontend tests and build

React Hook useEffect has a missing dependency: 'loadRuntimeConfig'. Either include it or remove the dependency array

useEffect(() => {
localStorage.setItem('notebooklm-theme', config.theme);
}, [config.theme]);

const profileToConfig = (current: ApiConfig, payload: any): ApiConfig => {

Check warning on line 211 in frontend/src/App.tsx

View workflow job for this annotation

GitHub Actions / Frontend tests and build

Unexpected any. Specify a different type
const models = payload?.models || {};
const text = models.text_model || {};
const embedding = models.embedding_model || {};
Expand Down Expand Up @@ -315,13 +319,19 @@
const response = await fetch('/api/artifacts');
if (!response.ok) return;
const payload = await response.json();
const artifacts = (payload.artifacts || []).map((artifact: any) => ({

Check warning on line 322 in frontend/src/App.tsx

View workflow job for this annotation

GitHub Actions / Frontend tests and build

Unexpected any. Specify a different type
id: artifact.id,
type: artifact.artifact_type,
title: artifact.title,
createdAt: new Date(artifact.created_at),
markdown: artifact.markdown,
payload: artifact.payload,
fileRefs: artifact.file_refs || [],
audioUrl: artifact.payload?.audio_url || null,
transcriptUrl: artifact.payload?.transcript_url || null,
audioFilename: artifact.payload?.audio_filename || null,
transcriptFilename: artifact.payload?.transcript_filename || null,
transcript: artifact.payload?.transcript,
downloadJsonUrl: `/api/artifacts/${artifact.id}/download?format=json`,
downloadMarkdownUrl: `/api/artifacts/${artifact.id}/download?format=markdown`,
downloadSvgUrl: artifact.artifact_type === 'infographic' ? `/api/artifacts/${artifact.id}/download?format=svg` : undefined
Expand Down
Loading
Loading