From 55af7a1a6a695e5cb6ecfcb76c77f910e3c0d647 Mon Sep 17 00:00:00 2001 From: Lriver Date: Wed, 8 Jul 2026 20:23:38 +0800 Subject: [PATCH 1/2] feat(studio): polish artifact viewers and podcast persistence --- backend/api/routes/podcast.py | 76 +++++++++++- backend/api/schemas/podcast.py | 4 + backend/domain/artifact_schemas.py | 7 ++ frontend/e2e/interactive-artifacts.spec.ts | 18 +++ frontend/e2e/non-ppt-workflow.spec.ts | 77 ++++++++++-- frontend/src/App.tsx | 10 ++ frontend/src/components/ArtifactViewer.tsx | 44 ++++++- frontend/src/components/StudioPanel.tsx | 41 ++++++- .../components/markdown-rendering.test.tsx | 113 ++++++++++++++++++ frontend/src/index.css | 70 ++++++++++- tests/test_podcast_workflow.py | 57 +++++++++ 11 files changed, 496 insertions(+), 21 deletions(-) diff --git a/backend/api/routes/podcast.py b/backend/api/routes/podcast.py index 2983d00..0c9b4a8 100644 --- a/backend/api/routes/podcast.py +++ b/backend/api/routes/podcast.py @@ -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 @@ -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, @@ -83,11 +99,60 @@ async def generate_podcast( detail="请提供 source_text 或 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"], @@ -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, diff --git a/backend/api/schemas/podcast.py b/backend/api/schemas/podcast.py index 35c991f..558ffae 100644 --- a/backend/api/schemas/podcast.py +++ b/backend/api/schemas/podcast.py @@ -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 diff --git a/backend/domain/artifact_schemas.py b/backend/domain/artifact_schemas.py index 844c952..ac0b267 100644 --- a/backend/domain/artifact_schemas.py +++ b/backend/domain/artifact_schemas.py @@ -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): diff --git a/frontend/e2e/interactive-artifacts.spec.ts b/frontend/e2e/interactive-artifacts.spec.ts index 36353f1..2ca8b27 100644 --- a/frontend/e2e/interactive-artifacts.spec.ts +++ b/frontend/e2e/interactive-artifacts.spec.ts @@ -47,6 +47,13 @@ const artifactPayloads: Record> = { ], key_takeaways: ['优先使用 HTTPS', '证书链需要可信'] }, + faq: { + title: 'HTTPS FAQ', + items: [ + { question: 'HTTPS 解决什么问题?', answer: '它通过 TLS 降低窃听、篡改和身份冒充风险。' }, + { question: '证书为什么重要?', answer: '证书把服务器身份和公钥绑定起来。' } + ] + }, data_table: { title: '协议对比表', columns: ['协议', '安全性', '用途'], @@ -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 => { diff --git a/frontend/e2e/non-ppt-workflow.spec.ts b/frontend/e2e/non-ppt-workflow.spec.ts index a57f7e1..f44d6c1 100644 --- a/frontend/e2e/non-ppt-workflow.spec.ts +++ b/frontend/e2e/non-ppt-workflow.spec.ts @@ -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> = []; + let artifacts: Array> = []; let artifactCount = 0; await page.route('**/api/config', route => route.fulfill({ @@ -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 }; @@ -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('/'); @@ -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: /Transcript/ })).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: /Transcript/ })).toHaveAttribute('href', /demo\.md/); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 95d0c06..3f6c6e6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -130,9 +130,13 @@ export interface GeneratedContent { title: string; createdAt: Date; audioUrl?: string | null; + transcriptUrl?: string | null; + audioFilename?: string | null; + transcriptFilename?: string | null; transcript?: string; markdown?: string; payload?: Record; + fileRefs?: Array>; downloadJsonUrl?: string; downloadMarkdownUrl?: string; downloadSvgUrl?: string; @@ -322,6 +326,12 @@ function App() { 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 diff --git a/frontend/src/components/ArtifactViewer.tsx b/frontend/src/components/ArtifactViewer.tsx index 84025ef..145e636 100644 --- a/frontend/src/components/ArtifactViewer.tsx +++ b/frontend/src/components/ArtifactViewer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useId, useMemo, useState } from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import type { GeneratedContent } from '../App'; import { MarkdownView } from './MarkdownView'; @@ -11,6 +11,41 @@ interface ArtifactViewerProps { const textValue = (value: unknown) => value === undefined || value === null ? '' : String(value); +const FAQViewer: React.FC<{ payload: AnyRecord; markdown?: string }> = ({ payload, markdown }) => { + const items = Array.isArray(payload.items) ? payload.items : []; + const [openIndex, setOpenIndex] = useState(null); + const answerIdPrefix = useId(); + + if (!items.length) { + return ; + } + + return ( +
+ {items.map((item: AnyRecord, index: number) => { + const expanded = openIndex === index; + const question = textValue(item.question); + const answerId = `${answerIdPrefix}-faq-answer-${index}`; + return ( +
+ + {expanded &&

{textValue(item.answer)}

} +
+ ); + })} +
+ ); +}; + const FlashcardsViewer: React.FC<{ payload: AnyRecord; markdown?: string }> = ({ payload, markdown }) => { const cards = Array.isArray(payload.cards) ? payload.cards : []; const quiz = Array.isArray(payload.quiz) ? payload.quiz : []; @@ -200,12 +235,14 @@ const ReportViewer: React.FC<{ payload: AnyRecord; markdown?: string }> = ({ pay const DataTableViewer: React.FC<{ payload: AnyRecord; markdown?: string }> = ({ payload, markdown }) => { const columns = Array.isArray(payload.columns) ? payload.columns : []; const rows = Array.isArray(payload.rows) ? payload.rows : []; + const title = textValue(payload.title || 'Data table'); if (!columns.length) { return ; } return ( -
+
+ {columns.map((column: unknown) => )} @@ -239,6 +276,9 @@ export const ArtifactViewer: React.FC = ({ content }) => { if (content.type === 'flashcards' || content.type === 'quiz') { return ; } + if (content.type === 'faq') { + return ; + } if (content.type === 'mind_map') { return ; } diff --git a/frontend/src/components/StudioPanel.tsx b/frontend/src/components/StudioPanel.tsx index df30487..5cf3e92 100644 --- a/frontend/src/components/StudioPanel.tsx +++ b/frontend/src/components/StudioPanel.tsx @@ -74,13 +74,43 @@ export const StudioPanel: React.FC = ({ sourceIds, config, con if (!response.ok) throw new Error(await response.text()); const data = await response.json(); onContentGenerated({ - id: crypto.randomUUID(), - type: 'podcast', - title: lang === 'zh' ? '播客脚本' : 'Podcast Script', + id: data.artifact_id || crypto.randomUUID(), + type: 'podcast_script', + title: data.title || (lang === 'zh' ? '播客脚本' : 'Podcast Script'), createdAt: new Date(), audioUrl: data.audio_url, + transcriptUrl: data.transcript_url, + audioFilename: data.audio_filename, + transcriptFilename: data.transcript_filename, transcript: data.transcript, - markdown: data.transcript + payload: { + title: data.title || (lang === 'zh' ? '播客脚本' : 'Podcast Script'), + speakers: data.speakers || [], + turns: data.turns || [], + estimated_duration_minutes: data.duration_minutes, + transcript: data.transcript, + audio_url: data.audio_url, + audio_status: data.audio_status, + duration_minutes: data.duration_minutes, + dialogue_count: data.dialogue_count + }, + fileRefs: [ + data.transcript_url ? { + format: 'markdown', + mime_type: 'text/markdown', + name: data.transcript_filename, + url: data.transcript_url + } : null, + data.audio_url ? { + format: 'mp3', + mime_type: 'audio/mpeg', + name: data.audio_filename, + url: data.audio_url + } : null + ].filter(Boolean) as Array>, + markdown: data.transcript, + downloadJsonUrl: data.artifact_id ? `/api/artifacts/${data.artifact_id}/download?format=json` : undefined, + downloadMarkdownUrl: data.artifact_id ? `/api/artifacts/${data.artifact_id}/download?format=markdown` : undefined }); } else { const response = await fetch('/api/artifacts/generate', { @@ -239,11 +269,12 @@ export const StudioPanel: React.FC = ({ sourceIds, config, con
{content.audioUrl &&
{title}
{textValue(column)}
diff --git a/frontend/src/components/StudioPanel.tsx b/frontend/src/components/StudioPanel.tsx index 5cf3e92..28d8240 100644 --- a/frontend/src/components/StudioPanel.tsx +++ b/frontend/src/components/StudioPanel.tsx @@ -274,7 +274,7 @@ export const StudioPanel: React.FC = ({ sourceIds, config, con {content.downloadJsonUrl && JSON} {content.downloadSvgUrl && SVG} {content.audioUrl && MP3} - {content.transcriptUrl && Transcript} + {content.transcriptUrl && {lang === 'zh' ? '转录文本' : 'Transcript'}} )} diff --git a/frontend/src/components/markdown-rendering.test.tsx b/frontend/src/components/markdown-rendering.test.tsx index e9b0e96..79aeed4 100644 --- a/frontend/src/components/markdown-rendering.test.tsx +++ b/frontend/src/components/markdown-rendering.test.tsx @@ -352,7 +352,7 @@ describe('Markdown rendering', () => { fireEvent.click(screen.getByRole('button', { name: '播客脚本 podcast_script' })); expect(screen.getByRole('link', { name: /MP3/ })).toHaveAttribute('href', '/api/podcast/download/demo.mp3'); - expect(screen.getByRole('link', { name: /Transcript/ })).toHaveAttribute('href', '/api/podcast/download/demo.md'); + expect(screen.getByRole('link', { name: /转录文本/ })).toHaveAttribute('href', '/api/podcast/download/demo.md'); expect(screen.getByRole('link', { name: /Markdown/ })).toHaveAttribute('href', '/api/artifacts/podcast-1/download?format=markdown'); }); @@ -380,7 +380,7 @@ describe('Markdown rendering', () => { /> ); - const scrollRegion = screen.getByLabelText('横向滚动查看完整表格'); + const scrollRegion = screen.getByLabelText('配置对比'); expect(scrollRegion).toHaveAttribute('role', 'region'); expect(scrollRegion).toHaveAttribute('tabindex', '0');
{title}