Explorar o código

重构多个服务的类型提示并提升代码可读性;优化播客制作与报告查看功能的前端布局与交互体验。

JJSun hai 4 meses
pai
achega
7bd7e4f6d9
Modificáronse 19 ficheiros con 985 adicións e 675 borrados
  1. 42 8
      Co-creation-projects/JJason-DeepCastAgent/README.md
  2. 7 8
      Co-creation-projects/JJason-DeepCastAgent/backend/pyproject.toml
  3. 98 9
      Co-creation-projects/JJason-DeepCastAgent/backend/src/agent.py
  4. 2 1
      Co-creation-projects/JJason-DeepCastAgent/backend/src/config.py
  5. 56 22
      Co-creation-projects/JJason-DeepCastAgent/backend/src/main.py
  6. 33 20
      Co-creation-projects/JJason-DeepCastAgent/backend/src/models.py
  7. 154 104
      Co-creation-projects/JJason-DeepCastAgent/backend/src/prompts.py
  8. 19 10
      Co-creation-projects/JJason-DeepCastAgent/backend/src/services/audio_generator.py
  9. 7 2
      Co-creation-projects/JJason-DeepCastAgent/backend/src/services/audio_synthesizer.py
  10. 13 7
      Co-creation-projects/JJason-DeepCastAgent/backend/src/services/notes.py
  11. 8 13
      Co-creation-projects/JJason-DeepCastAgent/backend/src/services/planner.py
  12. 18 11
      Co-creation-projects/JJason-DeepCastAgent/backend/src/services/reporter.py
  13. 82 129
      Co-creation-projects/JJason-DeepCastAgent/backend/src/services/script_generator.py
  14. 3 3
      Co-creation-projects/JJason-DeepCastAgent/backend/src/services/search.py
  15. 5 9
      Co-creation-projects/JJason-DeepCastAgent/backend/src/services/summarizer.py
  16. 0 1
      Co-creation-projects/JJason-DeepCastAgent/backend/src/services/text_processing.py
  17. 12 17
      Co-creation-projects/JJason-DeepCastAgent/backend/src/services/tool_events.py
  18. 5 9
      Co-creation-projects/JJason-DeepCastAgent/backend/src/utils.py
  19. 421 292
      Co-creation-projects/JJason-DeepCastAgent/frontend/src/App.vue

+ 42 - 8
Co-creation-projects/JJason-DeepCastAgent/README.md

@@ -26,6 +26,38 @@ DeepCast 旨在解决现代人在海量碎片化信息中难以获取深度知
 - **搜索增强**: Tavily API, SerpApi (Google Hybrid Search)
 - **音频处理**: Pydub, FFmpeg
 
+## 🧭 项目结构说明
+
+```
+.
+├─ backend/                 # 后端服务(FastAPI + 研究智能体)
+│  ├─ src/                  # 业务源码
+│  │  ├─ agent.py            # 研究流程编排器
+│  │  ├─ config.py           # 配置与环境变量加载
+│  │  ├─ main.py             # FastAPI 入口
+│  │  ├─ models.py           # 数据模型
+│  │  ├─ prompts.py          # 任务/报告/脚本提示词
+│  │  ├─ utils.py            # 工具函数
+│  │  └─ services/           # 业务服务
+│  │     ├─ search.py        # 搜索与多源检索
+│  │     ├─ summarizer.py    # 任务总结
+│  │     ├─ reporter.py      # 报告生成
+│  │     ├─ script_generator.py # 播客脚本生成
+│  │     ├─ audio_generator.py  # TTS 语音生成
+│  │     └─ audio_synthesizer.py # 音频合成
+│  ├─ output/               # 输出目录
+│  │  ├─ notes/             # 任务笔记与报告沉淀
+│  │  └─ audio/             # 生成的音频文件
+│  ├─ scripts/              # 开发与验证脚本
+│  ├─ env.example           # 环境变量示例
+│  └─ pyproject.toml         # Python 项目配置
+├─ frontend/                # 前端(Vue 3 + Vite)
+│  ├─ src/                  # 前端源码
+│  └─ index.html            # 入口页面
+├─ docs/                    # 文档与设计说明
+└─ README.md                # 项目说明
+```
+
 ## 🚀 快速开始
 
 ### 环境要求
@@ -119,16 +151,18 @@ DeepCast 将依次执行:
 - [ ] 接入多模态能力,支持生成播客视频(播客短视频剪辑)。
 - [ ] 支持用户上传个人私有知识库进行定制化研究。
 
-## 👤 作者
-
-- GitHub: [JJason-DeepCastAgent](https://github.com/Datawhale-HelloAgents/DeepCastAgent)
-
-## 🙏 致谢
+## 🤝 贡献指南
 
-- 感谢 [Datawhale](https://github.com/datawhalechina) 社区提供的学习资源与技术支持。
-- 感谢 **ECNU (华东师范大学)** 提供的强大模型与语音服务。
-- 感谢 [Hello-Agents](https://github.com/datawhalechina/Hello-Agents) 框架提供的灵活性。
+欢迎提出Issue和Pull Request!
 
 ## 📄 许可证
 
 MIT License
+
+## 👤 作者
+
+- GitHub: [JJason-DeepCastAgent](https://github.com/JJasonSun/hello-agents)
+
+## 🙏 致谢
+
+感谢Datawhale社区和Hello-Agents项目!

+ 7 - 8
Co-creation-projects/JJason-DeepCastAgent/backend/pyproject.toml

@@ -41,21 +41,20 @@ lint.select = [
     "E",    # pycodestyle
     "F",    # pyflakes
     "I",    # isort
+    "UP",   # pyupgrade
     "D",    # pydocstyle
-    "D401", # First line should be in imperative mood
-    "T201",
-    "UP",
+    "T20",  # flake8-print
 ]
 lint.ignore = [
-    "UP006",
-    "UP007",
-    "UP035",
-    "D417",
-    "E501",
+    "D400",  # 中文 docstring 句号检测误报
+    "D415",  # 中文 docstring 句号检测误报
+    "D212",  # 多行 docstring 首行格式
+    "E501",  # 行过长
 ]
 
 [tool.ruff.lint.per-file-ignores]
 "tests/*" = ["D", "UP"]
+"scripts/*" = ["T201"]
 
 [tool.ruff.lint.pydocstyle]
 convention = "google"

+ 98 - 9
Co-creation-projects/JJason-DeepCastAgent/backend/src/agent.py

@@ -4,28 +4,29 @@ from __future__ import annotations
 
 import logging
 import re
+from collections.abc import Callable, Iterator
 from pathlib import Path
 from queue import Empty, Queue
-from threading import Lock, Thread
-from typing import Any, Callable, Iterator
+from threading import Event, Lock, Thread
+from typing import Any
 
 from hello_agents import HelloAgentsLLM, ToolAwareSimpleAgent
 from hello_agents.tools import ToolRegistry
 from hello_agents.tools.builtin.note_tool import NoteTool
 
 from config import Configuration
+from models import SummaryState, SummaryStateOutput, TodoItem
 from prompts import (
     report_writer_instructions,
     script_writer_instructions,
     task_summarizer_instructions,
     todo_planner_system_prompt,
 )
-from models import SummaryState, SummaryStateOutput, TodoItem
+from services.audio_generator import AudioGenerationService
+from services.audio_synthesizer import PodcastSynthesisService
 from services.planner import PlanningService
 from services.reporter import ReportingService
 from services.script_generator import ScriptGenerationService
-from services.audio_generator import AudioGenerationService
-from services.audio_synthesizer import PodcastSynthesisService
 from services.search import dispatch_search, prepare_research_context
 from services.summarizer import SummarizationService
 from services.tool_events import ToolCallTracker
@@ -33,6 +34,11 @@ from services.tool_events import ToolCallTracker
 logger = logging.getLogger(__name__)
 
 
+class CancelledException(Exception):
+    """研究任务被用户取消时抛出的异常。"""
+    pass
+
+
 class DeepResearchAgent:
     """使用 HelloAgents 协调基于 TODO 的研究工作流的协调器。"""
 
@@ -59,6 +65,7 @@ class DeepResearchAgent:
         )
         self._tool_event_sink_enabled = False
         self._state_lock = Lock()
+        self._cancel_event = Event()  # 取消信号
 
         self.todo_agent = self._create_tool_aware_agent(
             name="研究规划专家",
@@ -91,6 +98,20 @@ class DeepResearchAgent:
         self.podcast_synthesizer = PodcastSynthesisService(self.config)
         self._last_search_notices: list[str] = []
 
+    def cancel(self) -> None:
+        """请求取消当前正在执行的研究任务。"""
+        logger.info("Cancel requested for research agent")
+        self._cancel_event.set()
+
+    def _check_cancelled(self) -> None:
+        """检查是否收到取消请求,如果是则抛出 CancelledException。"""
+        if self._cancel_event.is_set():
+            raise CancelledException("研究任务已被用户取消")
+
+    def is_cancelled(self) -> bool:
+        """检查当前任务是否已被取消。"""
+        return self._cancel_event.is_set()
+
     # ------------------------------------------------------------------
     # 公共 API
     # ------------------------------------------------------------------
@@ -167,7 +188,7 @@ class DeepResearchAgent:
         audio_files = self.audio_generator.generate_audio(script, task_id)
 
         # 合成播客
-        podcast_file = self.podcast_synthesizer.synthesize_podcast(audio_files, task_id)
+        self.podcast_synthesizer.synthesize_podcast(audio_files, task_id)
         
         return SummaryStateOutput(
             running_summary=report,
@@ -187,11 +208,21 @@ class DeepResearchAgent:
         3. 实时流式传输任务状态、搜索结果和部分总结。
         4. 所有任务完成后,生成并流式传输最终报告。
         5. 生成并流式传输播客脚本和音频合成进度。
+        
+        支持通过 cancel() 方法取消执行。
         """
+        # 重置取消状态
+        self._cancel_event.clear()
+        
         state = SummaryState(research_topic=topic)
         logger.debug("Starting streaming research: topic=%s", topic)
         yield {"type": "status", "message": "初始化研究流程"}
 
+        # 检查取消
+        if self.is_cancelled():
+            yield {"type": "cancelled", "message": "研究任务已取消"}
+            return
+
         state.todo_items = self.planner.plan_todo_list(state)
         for event in self._drain_tool_events(state, step=0):
             yield event
@@ -245,6 +276,11 @@ class DeepResearchAgent:
 
         def worker(task: TodoItem, step: int) -> None:
             try:
+                # 检查取消状态
+                if self.is_cancelled():
+                    enqueue({"type": "__task_done__", "task_id": task.id})
+                    return
+                    
                 enqueue(
                     {
                         "type": "task_status",
@@ -260,7 +296,12 @@ class DeepResearchAgent:
                 )
 
                 for event in self._execute_task(state, task, emit_stream=True, step=step):
+                    # 在每个事件之后检查取消
+                    if self.is_cancelled():
+                        break
                     enqueue(event, task=task)
+            except CancelledException:
+                logger.info("Task %s cancelled", task.id)
             except Exception as exc:  # pragma: no cover - defensive guardrail
                 logger.exception("Task execution failed", exc_info=exc)
                 enqueue(
@@ -291,7 +332,17 @@ class DeepResearchAgent:
 
         try:
             while finished_workers < active_workers:
-                event = event_queue.get()
+                # 使用带超时的 get 以便定期检查取消状态
+                try:
+                    event = event_queue.get(timeout=0.5)
+                except Empty:
+                    # 检查是否取消
+                    if self.is_cancelled():
+                        logger.info("Research cancelled during task execution")
+                        yield {"type": "cancelled", "message": "研究任务已取消"}
+                        return
+                    continue
+                    
                 if event.get("type") == "__task_done__":
                     finished_workers += 1
                     continue
@@ -307,7 +358,12 @@ class DeepResearchAgent:
         finally:
             self._set_tool_event_sink(None)
             for thread in threads:
-                thread.join()
+                thread.join(timeout=1.0)
+
+        # 检查取消
+        if self.is_cancelled():
+            yield {"type": "cancelled", "message": "研究任务已取消"}
+            return
 
         yield {
             "type": "stage_change",
@@ -323,6 +379,11 @@ class DeepResearchAgent:
         state.running_summary = report
         yield {"type": "log", "message": f"✓ 报告撰写完成,共 {len(report)} 字符"}
 
+        # 检查取消
+        if self.is_cancelled():
+            yield {"type": "cancelled", "message": "研究任务已取消"}
+            return
+
         note_event = self._persist_final_report(state, report)
         if note_event:
             yield note_event
@@ -334,6 +395,11 @@ class DeepResearchAgent:
             "note_path": state.report_note_path,
         }
 
+        # 检查取消
+        if self.is_cancelled():
+            yield {"type": "cancelled", "message": "研究任务已取消"}
+            return
+
         yield {
             "type": "stage_change",
             "stage": "script",
@@ -358,6 +424,11 @@ class DeepResearchAgent:
             "turns": script_turns,
         }
 
+        # 检查取消
+        if self.is_cancelled():
+            yield {"type": "cancelled", "message": "研究任务已取消"}
+            return
+
         yield {
             "type": "stage_change",
             "stage": "audio",
@@ -369,9 +440,13 @@ class DeepResearchAgent:
         audio_event_queue: Queue[dict[str, Any]] = Queue()
         audio_result: list = []
         audio_error: list = []
+        cancel_audio = Event()  # 用于取消音频生成的信号
         
         def audio_progress_callback(current, total, role, preview):
             """将进度事件放入队列以实现实时更新"""
+            # 检查是否应该取消
+            if self.is_cancelled() or cancel_audio.is_set():
+                return False  # 返回 False 表示应该停止
             audio_event_queue.put({
                 "type": "audio_progress",
                 "current": current,
@@ -380,6 +455,7 @@ class DeepResearchAgent:
                 "preview": preview,
                 "message": f"[TTS {current}/{total}] 正在为 {role} 生成语音: {preview}",
             })
+            return True  # 返回 True 表示继续
         
         def run_audio_generation():
             """在单独线程中运行音频生成"""
@@ -387,7 +463,8 @@ class DeepResearchAgent:
                 files = self.audio_generator.generate_audio(script, task_id, audio_progress_callback)
                 audio_result.append(files)
             except Exception as e:
-                audio_error.append(str(e))
+                if not self.is_cancelled():
+                    audio_error.append(str(e))
             finally:
                 audio_event_queue.put({"type": "_audio_done"})
         
@@ -405,6 +482,13 @@ class DeepResearchAgent:
         
         # 实时流式传输进度事件
         while True:
+            # 检查取消
+            if self.is_cancelled():
+                cancel_audio.set()  # 通知音频生成线程停止
+                yield {"type": "cancelled", "message": "研究任务已取消"}
+                audio_thread.join(timeout=2.0)
+                return
+                
             try:
                 event = audio_event_queue.get(timeout=0.1)
                 if event.get("type") == "_audio_done":
@@ -421,6 +505,11 @@ class DeepResearchAgent:
         
         audio_thread.join(timeout=5.0)
         
+        # 检查取消
+        if self.is_cancelled():
+            yield {"type": "cancelled", "message": "研究任务已取消"}
+            return
+        
         audio_files = audio_result[0] if audio_result else []
         audio_count = len(audio_files) if audio_files else 0
         

+ 2 - 1
Co-creation-projects/JJason-DeepCastAgent/backend/src/config.py

@@ -9,6 +9,7 @@ from pydantic import BaseModel, Field, field_validator
 BACKEND_ROOT = Path(__file__).resolve().parent.parent
 
 class SearchAPI(Enum):
+    """可用的搜索 API 提供商。"""
     HYBRID = "hybrid"
     TAVILY = "tavily"
     SERPAPI = "serpapi"
@@ -78,7 +79,7 @@ class Configuration(BaseModel):
         description="Model ID for complex reasoning tasks (e.g. Planning, Reporting)",
     )
     fast_llm_model: Optional[str] = Field(
-        default="ecnu-plus",
+        default="ecnu-max",
         title="Fast LLM Model",
         description="Model ID for simple/fast tasks (e.g. Summarization)",
     )

+ 56 - 22
Co-creation-projects/JJason-DeepCastAgent/backend/src/main.py

@@ -2,23 +2,24 @@
 
 from __future__ import annotations
 
+import asyncio
 import json
 import os
 import sys
-from typing import Any, Dict, Iterator, Optional
+from typing import Any
 
 # Ensure src directory is in sys.path for module imports
 sys.path.append(os.path.dirname(os.path.abspath(__file__)))
 
-from fastapi import FastAPI, HTTPException
+from fastapi import FastAPI, HTTPException, Request
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import StreamingResponse
 from fastapi.staticfiles import StaticFiles
 from loguru import logger
 from pydantic import BaseModel, Field
 
-from config import Configuration, SearchAPI
 from agent import DeepResearchAgent
+from config import Configuration, SearchAPI
 
 # 添加控制台日志处理程序
 logger.add(
@@ -62,13 +63,13 @@ class ResearchResponse(BaseModel):
         default_factory=list,
         description="带有摘要和来源的结构化待办事项",
     )
-    podcast_script: Optional[PodcastScript] = Field(
+    podcast_script: PodcastScript | None = Field(
         default=None,
         description="生成的播客脚本内容",
     )
 
 
-def _mask_secret(value: Optional[str], visible: int = 4) -> str:
+def _mask_secret(value: str | None, visible: int = 4) -> str:
     """在保持前导和尾随字符的同时,掩盖敏感令牌。"""
     if not value:
         return "unset"
@@ -80,7 +81,7 @@ def _mask_secret(value: Optional[str], visible: int = 4) -> str:
 
 
 def _build_config(payload: ResearchRequest) -> Configuration:
-    overrides: Dict[str, Any] = {}
+    overrides: dict[str, Any] = {}
 
     if payload.search_api is not None:
         overrides["search_api"] = payload.search_api
@@ -89,6 +90,7 @@ def _build_config(payload: ResearchRequest) -> Configuration:
 
 
 def create_app() -> FastAPI:
+    """创建并配置 FastAPI 应用实例。"""
     app = FastAPI(title="DeepCast - 自动播客生成智能体")
 
     app.add_middleware(
@@ -113,19 +115,12 @@ def create_app() -> FastAPI:
         """记录启动时的关键配置参数。"""
         config = Configuration.from_env()
 
-        if config.llm_provider == "ollama":
-            base_url = config.sanitized_ollama_url()
-        elif config.llm_provider == "lmstudio":
-            base_url = config.lmstudio_base_url
-        else:
-            base_url = config.llm_base_url or "unset"
-
         logger.info(
             "DeepResearch configuration loaded: provider=%s model=%s base_url=%s search_api=%s "
             "max_loops=%s fetch_full_page=%s tool_calling=%s strip_thinking=%s api_key=%s",
             config.llm_provider,
             config.resolved_model() or "unset",
-            base_url,
+            config.llm_base_url or "unset",
             (config.search_api.value if isinstance(config.search_api, SearchAPI) else config.search_api),
             config.max_web_research_loops,
             config.fetch_full_page,
@@ -135,7 +130,7 @@ def create_app() -> FastAPI:
         )
 
     @app.get("/healthz")
-    def health_check() -> Dict[str, str]:
+    def health_check() -> dict[str, str]:
         return {"status": "ok"}
 
     @app.post("/research", response_model=ResearchResponse)
@@ -169,21 +164,29 @@ def create_app() -> FastAPI:
             for item in result.todo_items
         ]
 
-        # 添加podcast_script字段到返回响应中
-        podcast_script = result.podcast_script or PodcastScript(script="")
+        # 确保 podcast_script 类型正确,Pydantic 模型需要 PodcastScript 实例
+        script_content = ""
+        if result.podcast_script:
+            if isinstance(result.podcast_script, (list, dict)):
+                script_content = json.dumps(result.podcast_script, ensure_ascii=False)
+            else:
+                script_content = str(result.podcast_script)
+        
+        podcast_resp = PodcastScript(script=script_content)
 
         return ResearchResponse(
             report_markdown=(result.report_markdown or result.running_summary or ""),
             todo_items=todo_payload,
-            podcast_script=podcast_script,
+            podcast_script=podcast_resp,
         )
 
     @app.post("/research/stream")
-    def stream_research(payload: ResearchRequest) -> StreamingResponse:
+    async def stream_research(payload: ResearchRequest, request: Request) -> StreamingResponse:
         """
         触发流式研究任务。
         
         通过 Server-Sent Events (SSE) 实时返回研究进度、日志和部分结果。
+        支持客户端断开连接时自动取消后端任务。
         """
         try:
             config = _build_config(payload)
@@ -191,10 +194,41 @@ def create_app() -> FastAPI:
         except ValueError as exc:
             raise HTTPException(status_code=400, detail=str(exc)) from exc
 
-        def event_iterator() -> Iterator[str]:
+        async def event_iterator():
             try:
-                for event in agent.run_stream(payload.topic):
-                    yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
+                # 在线程池中运行同步生成器
+                import concurrent.futures
+                with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
+                    # 将生成器转换为可在线程中运行的迭代
+                    gen = agent.run_stream(payload.topic)
+                    
+                    while True:
+                        # 检查客户端是否断开连接
+                        if await request.is_disconnected():
+                            logger.info("Client disconnected, cancelling research task")
+                            agent.cancel()
+                            break
+                        
+                        # 在线程池中获取下一个事件
+                        loop = asyncio.get_event_loop()
+                        try:
+                            event = await asyncio.wait_for(
+                                loop.run_in_executor(executor, lambda: next(gen, None)),
+                                timeout=1.0
+                            )
+                        except asyncio.TimeoutError:
+                            # 超时时继续检查连接状态
+                            continue
+                        
+                        if event is None:
+                            break
+                            
+                        yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
+                        
+                        # 如果是完成或取消事件,退出循环
+                        if event.get("type") in ("done", "cancelled", "error"):
+                            break
+                            
             except Exception as exc:  # pragma: no cover - defensive guardrail
                 logger.exception("Streaming research failed")
                 error_payload = {"type": "error", "detail": str(exc)}

+ 33 - 20
Co-creation-projects/JJason-DeepCastAgent/backend/src/models.py

@@ -2,9 +2,7 @@
 
 import operator
 from dataclasses import dataclass, field
-from typing import List, Optional
-
-from typing_extensions import Annotated
+from typing import Annotated
 
 
 @dataclass(kw_only=True)
@@ -16,38 +14,53 @@ class TodoItem:
     intent: str
     query: str
     status: str = field(default="pending")
-    summary: Optional[str] = field(default=None)
-    sources_summary: Optional[str] = field(default=None)
+    summary: str | None = field(default=None)
+    sources_summary: str | None = field(default=None) 
     notices: list[str] = field(default_factory=list)
-    note_id: Optional[str] = field(default=None)
-    note_path: Optional[str] = field(default=None)
-    stream_token: Optional[str] = field(default=None)
+    note_id: str | None = field(default=None)
+    note_path: str | None = field(default=None)
+    stream_token: str | None = field(default=None)
 
 
 @dataclass(kw_only=True)
 class SummaryState:
-    research_topic: str = field(default=None)  # 研究主题
-    search_query: str = field(default=None)  # 已弃用的占位符
+    """深度研究工作流的状态模型。
+    
+    用于追踪研究主题、搜索结果、待办任务和生成的报告。
+    """
+
+    research_topic: str | None = field(default=None)  # 研究主题
+    search_query: str | None = field(default=None)  # 已弃用的占位符
     web_research_results: Annotated[list, operator.add] = field(default_factory=list)
     sources_gathered: Annotated[list, operator.add] = field(default_factory=list)
     research_loop_count: int = field(default=0)  # 研究循环次数
-    running_summary: str = field(default=None)  # 传统摘要字段
+    running_summary: str | None = field(default=None)  # 传统摘要字段
     todo_items: Annotated[list, operator.add] = field(default_factory=list)  # 待办任务项列表
-    structured_report: Optional[str] = field(default=None)  # 结构化报告(JSON 字符串)
-    report_note_id: Optional[str] = field(default=None)  # 报告笔记 ID
-    report_note_path: Optional[str] = field(default=None)  # 报告笔记路径
-    podcast_script: Optional[list] = field(default=None)  # 播客脚本(JSON 字符串)
+    structured_report: str | None = field(default=None)  # 结构化报告(JSON 字符串)
+    report_note_id: str | None = field(default=None)  # 报告笔记 ID
+    report_note_path: str | None = field(default=None)  # 报告笔记路径
+    podcast_script: list | None = field(default=None)  # 播客脚本(JSON 字符串)
 
 
 @dataclass(kw_only=True)
 class SummaryStateInput:
-    research_topic: str = field(default=None)  # 研究主题
+    """深度研究工作流的输入状态模型。
+    
+    用于指定研究主题。
+    """
+
+    research_topic: str | None = field(default=None)  # 研究主题
 
 
 @dataclass(kw_only=True)
 class SummaryStateOutput:
-    running_summary: str = field(default=None)  # 向后兼容的摘要文本
-    report_markdown: Optional[str] = field(default=None)
-    todo_items: List[TodoItem] = field(default_factory=list)
-    podcast_script: Optional[list] = field(default=None)
+    """深度研究工作流的输出状态模型。
+    
+    用于返回研究摘要、报告、待办任务和播客脚本。
+    """
+
+    running_summary: str | None = field(default=None)  # 向后兼容的摘要文本
+    report_markdown: str | None = field(default=None)
+    todo_items: list[TodoItem] = field(default_factory=list)
+    podcast_script: list | None = field(default=None)
 

+ 154 - 104
Co-creation-projects/JJason-DeepCastAgent/backend/src/prompts.py

@@ -1,140 +1,190 @@
 from datetime import datetime
 
 
-# 以可读格式获取当前日期
 def get_current_date():
-    return datetime.now().strftime("%B %d, %Y")
-
+    """以中文格式获取当前日期"""
+    return datetime.now().strftime("%Y年%m月%d日")
 
 
+# ============================================================
+# 研究规划专家 - 任务拆解
+# ============================================================
 todo_planner_system_prompt = """
-你是一名研究规划专家,请把复杂主题拆解为一组有限、互补的待办任务。
-- 任务之间应互补,避免重复;
-- 每个任务要有明确意图与可执行的检索方向;
-- 输出须结构化、简明且便于后续协作。
-
-<GOAL>
-1. 结合研究主题梳理 3~5 个最关键的调研任务;
-2. 每个任务需明确目标意图,并给出适宜的网络检索查询;
-3. 任务之间要避免重复,整体覆盖用户的问题域;
-4. 在创建或更新任务时,必须调用 `note` 工具同步任务信息(这是唯一会写入笔记的途径)。
-</GOAL>
-
-<NOTE_COLLAB>
-- 为每个任务调用 `note` 工具创建/更新结构化笔记,统一使用 JSON 参数格式:
-  - 创建示例:`[TOOL_CALL:note:{"action":"create","task_id":1,"title":"任务 1: 背景梳理","note_type":"task_state","tags":["deep_research","task_1"],"content":"请记录任务概览、系统提示、来源概览、任务总结"}]`
-  - 更新示例:`[TOOL_CALL:note:{"action":"update","note_id":"<现有ID>","task_id":1,"title":"任务 1: 背景梳理","note_type":"task_state","tags":["deep_research","task_1"],"content":"...新增内容..."}]`
-- `tags` 必须包含 `deep_research` 与 `task_{task_id}`,以便其他 Agent 查找
-</NOTE_COLLAB>
-
-<TOOLS>
-你必须调用名为 `note` 的笔记工具来记录或更新待办任务,参数统一使用 JSON:
-```
-[TOOL_CALL:note:{"action":"create","task_id":1,"title":"任务 1: 背景梳理","note_type":"task_state","tags":["deep_research","task_1"],"content":"..."}]
-```
-</TOOLS>
+你是一名资深的研究规划专家,专注于将复杂主题拆解为结构化、可执行的研究任务。
+
+<核心职责>
+1. 分析研究主题,识别关键维度和子问题
+2. 设计 3-5 个互补且不重复的调研任务
+3. 为每个任务制定明确的检索策略
+4. 使用笔记工具记录和同步任务状态
+</核心职责>
+
+<任务设计原则>
+- 覆盖性:任务组合应全面覆盖研究主题的核心方面
+- 互补性:避免任务之间的内容重叠
+- 可执行性:每个任务都有明确的检索方向
+- 深度性:兼顾基础背景和前沿动态
+</任务设计原则>
+
+<笔记工具使用>
+使用 `note` 工具记录任务,参数为 JSON 格式:
+- 创建笔记:`[TOOL_CALL:note:{"action":"create","task_id":1,"title":"任务标题","note_type":"task_state","tags":["deep_research","task_1"],"content":"任务内容"}]`
+- 更新笔记:`[TOOL_CALL:note:{"action":"update","note_id":"<ID>","task_id":1,"title":"任务标题","note_type":"task_state","tags":["deep_research","task_1"],"content":"更新内容"}]`
+</笔记工具使用>
 """
 
 
 todo_planner_instructions = """
-
-<CONTEXT>
+<研究上下文>
 当前日期:{current_date}
 研究主题:{research_topic}
-</CONTEXT>
+</研究上下文>
 
-<FORMAT>
-请严格以 JSON 格式回复
+<输出格式>
+请以 JSON 格式输出任务列表
 {{
   "tasks": [
     {{
-      "title": "任务名称(10字内,突出重点)",
-      "intent": "任务要解决的核心问题,用1-2句描述",
-      "query": "建议使用的检索关键词"
+      "title": "简洁的任务名称(不超过10字)",
+      "intent": "该任务要解答的核心问题(1-2句话)",
+      "query": "推荐的搜索关键词或查询语句"
     }}
   ]
 }}
-</FORMAT>
+</输出格式>
 
-即使主题较为模糊或开放(例如“聊聊XX的未来”),也请不要放弃!请基于行业常识和通用研究框架(如:技术原理、应用场景、市场影响、潜在挑战、竞品对比等)主动构建 3-5 个合理的探索性任务。
-请确保输出的 JSON 格式正确,不要输出空数组。
+<重要提示>
+- 即使主题较为宽泛(如"聊聊XX的发展"),也请主动从技术原理、应用场景、市场格局、发展趋势、挑战与机遇等角度构建研究任务
+- 确保每个任务的 query 具有实际可检索性
+- 输出有效的 JSON 格式,tasks 数组不能为空
+</重要提示>
 """
 
 
+# ============================================================
+# 任务执行专家 - 内容总结
+# ============================================================
 task_summarizer_instructions = """
-你是一名研究执行专家,请基于给定的上下文,为特定任务生成要点总结,对内容进行详尽且细致的总结而不是走马观花,需要勇于创新、打破常规思维,并尽可能多维度,从原理、应用、优缺点、工程实践、对比、历史演变等角度进行拓展。
-
-<GOAL>
-1. 针对任务意图梳理 3-5 条关键发现;
-2. 清晰说明每条发现的含义与价值,可引用事实数据;
-</GOAL>
-
-<NOTES>
-- 任务笔记由规划专家创建,笔记 ID 会在调用时提供;请先调用 `[TOOL_CALL:note:{"action":"read","note_id":"<note_id>"}]` 获取最新状态。
-- 更新任务总结后,使用 `[TOOL_CALL:note:{"action":"update","note_id":"<note_id>","task_id":{task_id},"title":"任务 {task_id}: …","note_type":"task_state","tags":["deep_research","task_{task_id}"],"content":"..."}]` 写回笔记,保持原有结构并追加新信息。
-- 若未找到笔记 ID,请先创建并在 `tags` 中包含 `task_{task_id}` 后再继续。
-</NOTES>
-
-<FORMAT>
-- **直接**输出 Markdown 格式的总结内容。
-- **严禁**包含任何自我陈述、规划或废话(如“我将...”、“基于...”)。
-- 以小节标题开头:"任务总结";
-- 关键发现使用有序或无序列表表达;
-- 若任务无有效结果,输出"暂无可用信息"。
-- 最终呈现给用户的总结中禁止包含 `[TOOL_CALL:...]` 指令。
-</FORMAT>
+你是一名研究执行专家,负责对检索到的信息进行深度分析和结构化总结。
+
+<核心要求>
+1. 基于检索结果,提炼 3-5 条有价值的关键发现
+2. 每条发现需包含具体的事实、数据或案例支撑
+3. 从多个维度分析:原理机制、实际应用、优劣势、发展趋势、行业对比等
+4. 保持客观中立,区分事实与观点
+</核心要求>
+
+<笔记协作>
+- 使用 `[TOOL_CALL:note:{"action":"read","note_id":"<note_id>"}]` 读取已有笔记
+- 使用 `[TOOL_CALL:note:{"action":"update","note_id":"<note_id>",...}]` 更新笔记内容
+</笔记协作>
+
+<输出规范>
+- 直接输出 Markdown 格式的总结内容
+- 以"任务总结"作为标题
+- 使用有序或无序列表呈现关键发现
+- 禁止输出"我将..."、"根据..."等废话
+- 若无有效信息,输出"暂无可用信息"
+- 最终输出不得包含 `[TOOL_CALL:...]` 指令
+</输出规范>
 """
 
 
+# ============================================================
+# 报告撰写专家 - 研究报告生成
+# ============================================================
 report_writer_instructions = """
-你是一名专业的分析报告撰写者,请根据输入的任务总结与参考信息,生成结构化的研究报告。
-
-<REPORT_TEMPLATE>
-1. **背景概览**:简述研究主题的重要性与上下文。
-2. **核心洞见**:提炼 3-5 条最重要的结论,标注文献/任务编号。
-3. **证据与数据**:罗列支持性的事实或指标,可引用任务摘要中的要点。
-4. **风险与挑战**:分析潜在的问题、限制或仍待验证的假设。
-5. **参考来源**:按任务列出关键来源条目(标题 + 链接)。
-</REPORT_TEMPLATE>
-
-<REQUIREMENTS>
-- 报告使用 Markdown;
-- 各部分明确分节,禁止添加额外的封面或结语;
-- 若某部分信息缺失,说明"暂无相关信息";
-- 引用来源时使用任务标题或来源标题,确保可追溯。
-- 输出给用户的内容中禁止残留 `[TOOL_CALL:...]` 指令。
-</REQUIREMENTS>
-
-<NOTES>
-- 报告生成前,请针对每个 note_id 调用 `[TOOL_CALL:note:{"action":"read","note_id":"<note_id>"}]` 读取任务笔记。
-- 如需在报告层面沉淀结果,可创建新的 `conclusion` 类型笔记,例如:`[TOOL_CALL:note:{"action":"create","title":"研究报告:{研究主题}","note_type":"conclusion","tags":["deep_research","report"],"content":"...报告要点..."}]`。
-</NOTES>
+你是一名专业的分析报告撰写专家,负责将多个任务的研究结果整合为一份结构化的深度报告。
+
+<报告结构>
+1. **背景概览**
+   - 研究主题的定义与重要性
+   - 当前发展阶段与行业背景
+
+2. **核心洞见**
+   - 提炼 3-5 条最重要的研究发现
+   - 每条洞见需有明确的数据或案例支撑
+   - 标注信息来源(任务编号或来源名称)
+
+3. **深度分析**
+   - 技术原理或核心机制解析
+   - 典型应用场景与案例
+   - 市场格局与竞争态势
+
+4. **风险与挑战**
+   - 当前存在的主要问题
+   - 潜在风险与不确定性
+   - 待验证的假设
+
+5. **参考来源**
+   - 列出研究过程中实际引用的外部来源(网页、论文、报告等)
+   - 格式:来源标题 + URL 链接
+   - **注意**:只列出搜索结果中的实际外部来源,不要引用"任务总结"或内部笔记
+</报告结构>
+
+<写作要求>
+- 使用 Markdown 格式
+- 语言专业、逻辑清晰
+- 参考来源必须来自搜索结果中的实际网页/文献,不要引用任务总结本身
+- 信息缺失时注明"暂无相关信息"
+- 禁止在输出中包含 `[TOOL_CALL:...]` 指令
+</写作要求>
+
+<笔记协作>
+- 报告生成前,使用 `[TOOL_CALL:note:{"action":"read","note_id":"<note_id>"}]` 读取各任务笔记
+- 可创建报告级别的总结笔记:`[TOOL_CALL:note:{"action":"create","title":"研究报告","note_type":"conclusion","tags":["deep_research","report"],"content":"..."}]`
+</笔记协作>
 """
 
-script_writer_instructions = """
-你是一名专业的播客策划人,擅长将严肃的研究报告转化为生动、有趣且有深度的双人对话脚本。
-
-<ROLES>
-- **Host (Xiayu)**: 男声。好奇心强、幽默风趣、善于引导话题、负责提问和总结,代表普通听众的视角。
-- **Guest (Liwa)**: 女声。领域专家、知识渊博、理性客观、负责深入解释和提供见解,偶尔也会用幽默化解枯燥。
-</ROLES>
 
-<GOAL>
-根据提供的【研究报告】,创作一份高质量的播客对话脚本。对话应自然流畅,像两个老朋友在聊天,避免生硬的朗读感。
-
-<REQUIREMENTS>
-1. **开场**:Host 热情开场,引出话题,Guest 简短回应。
-2. **主体**:深入浅出地讨论报告中的核心观点、数据和案例。Host 负责追问“为什么”、“怎么做”或“这对普通人有什么影响”,Guest 负责解答。
-3. **结尾**:Host 总结核心收获,Guest 给出简短的未来展望或金句。Host 结束语。
-4. **风格**:轻松、口语化,适当使用感叹词(如“哇”、“原来如此”、“确实”),但不要过度。
-5. **长度**:对话回合数控制在 8-12 轮之间,确保内容充实但不拖沓,且避免输出过长导致截断。
-
-<FORMAT>
-请严格输出 JSON 格式的列表,不包含 Markdown 代码块标记(如 ```json ... ```),直接输出 JSON 数组:
+# ============================================================
+# 播客脚本专家 - 对话脚本生成
+# ============================================================
+script_writer_instructions = """
+你是一名资深播客策划人,擅长将深度研究报告转化为生动有趣的双人对话脚本。
+
+<主持人设定>
+- **Host(夏雨)**:男声主持人
+  - 性格:好奇心强、幽默风趣、善于引导
+  - 角色:代表普通听众视角,负责提问和总结
+  - 风格:活泼亲和,善于用生活化的比喻
+
+- **Guest(李娃)**:女声嘉宾
+  - 性格:专业严谨、博学多才、表达清晰
+  - 角色:领域专家,负责深入解释和专业解答
+  - 风格:深入浅出,偶尔用幽默化解专业术语
+</主持人设定>
+
+<脚本要求>
+1. **开场白**
+   - Host 热情开场,简要引出今天的话题
+   - Guest 简短回应,引发听众兴趣
+
+2. **主体对话**
+   - 围绕报告核心内容展开讨论
+   - Host 提出问题:"为什么?"、"怎么做?"、"这意味着什么?"
+   - Guest 专业解答,辅以案例和数据
+   - 适当插入互动:追问、补充、总结
+
+3. **结尾收束**
+   - Host 总结本期核心收获(2-3点)
+   - Guest 给出未来展望或金句
+   - Host 结束语,预告下期或感谢收听
+
+4. **语言风格**
+   - 自然流畅,像朋友聊天
+   - 适度使用口语化表达("哇"、"确实"、"原来如此")
+   - 避免生硬的书面语和过度的感叹
+</脚本要求>
+
+<输出格式>
+直接输出 JSON 数组,不要包含 Markdown 代码块标记:
 [
-  {"role": "Host", "content": "欢迎大家收听 DeepCast。今天我们要聊的话题很有意思..."},
-  {"role": "Guest", "content": "是的,这确实是一个非常前沿的领域..."},
+  {"role": "Host", "content": "大家好,欢迎收听 DeepCast!今天我们要聊一个特别有意思的话题..."},
+  {"role": "Guest", "content": "是的,这个话题最近确实很火,我来给大家深入分析一下..."},
   ...
 ]
-</FORMAT>
+
+对话轮次控制在 8-12 轮,确保内容充实且不拖沓。
+</输出格式>
 """

+ 19 - 10
Co-creation-projects/JJason-DeepCastAgent/backend/src/services/audio_generator.py

@@ -3,13 +3,12 @@
 from __future__ import annotations
 
 import logging
-import os
-import requests
+from collections.abc import Callable
 from pathlib import Path
-from typing import List, Optional, Callable
+
+import requests
 
 from config import Configuration
-from pydub import AudioSegment
 
 logger = logging.getLogger(__name__)
 
@@ -18,6 +17,12 @@ class AudioGenerationService:
     """处理与 TTS 服务的交互以生成音频文件。"""
 
     def __init__(self, config: Configuration) -> None:
+        """
+        初始化音频生成服务。
+
+        Args:
+            config: 包含 TTS 配置和输出路径的配置对象。
+        """
         self._config = config
         self._output_dir = Path(config.audio_output_dir)
         self._ensure_output_dir()
@@ -37,17 +42,18 @@ class AudioGenerationService:
 
     def generate_audio(
         self, 
-        script: List[dict[str, str]], 
+        script: list[dict[str, str]], 
         task_id: str = "default",
-        progress_callback: Optional[Callable[[int, int, str, str], None]] = None
-    ) -> List[str]:
+        progress_callback: Callable[[int, int, str, str], bool | None] | None = None
+    ) -> list[str]:
         """
         为给定的脚本生成音频文件。
         
         Args:
             script: 对话回合列表,例如 [{"role": "Host", "content": "..."}, ...]
             task_id: 当前任务/会话的唯一标识符
-            progress_callback: 可选的进度回调函数,签名为 (current, total, role, content_preview) -> None
+            progress_callback: 可选的进度回调函数,签名为 (current, total, role, content_preview) -> Optional[bool]
+                              返回 False 表示应该停止生成,返回 True 或 None 表示继续
             
         Returns:
             生成的音频文件的路径列表
@@ -70,10 +76,13 @@ class AudioGenerationService:
             if not role or not content:
                 continue
             
-            # 调用进度回调
+            # 调用进度回调,检查是否应该停止
             if progress_callback:
                 content_preview = content[:30] + "..." if len(content) > 30 else content
-                progress_callback(index + 1, total, role, content_preview)
+                should_continue = progress_callback(index + 1, total, role, content_preview)
+                if should_continue is False:
+                    logger.info("Audio generation cancelled by callback")
+                    break
                 
             voice_id = self._get_voice_for_role(role)
             if not voice_id:

+ 7 - 2
Co-creation-projects/JJason-DeepCastAgent/backend/src/services/audio_synthesizer.py

@@ -4,7 +4,6 @@ from __future__ import annotations
 
 import logging
 from pathlib import Path
-from typing import List
 
 from pydub import AudioSegment
 
@@ -17,6 +16,12 @@ class PodcastSynthesisService:
     """将多个音频片段组合成最终的播客文件。"""
 
     def __init__(self, config: Configuration) -> None:
+        """
+        初始化音频合成服务。
+
+        Args:
+            config: 包含 ffmpeg 路径和输出路径的配置对象。
+        """
         self._config = config
         self._output_dir = Path(config.audio_output_dir)
         
@@ -28,7 +33,7 @@ class PodcastSynthesisService:
         # 确保 pydub/ffmpeg 可用 - 假设 ffmpeg 已安装在系统中
         # 如果没有,pydub 可能会发出警告或失败,但我们会捕获异常。
 
-    def synthesize_podcast(self, audio_files: List[str], task_id: str = "default") -> str | None:
+    def synthesize_podcast(self, audio_files: list[str], task_id: str = "default") -> str | None:
         """
         将音频文件组合成单个播客 MP3。
 

+ 13 - 7
Co-creation-projects/JJason-DeepCastAgent/backend/src/services/notes.py

@@ -9,13 +9,13 @@ from models import TodoItem
 
 def build_note_guidance(task: TodoItem) -> str:
     """为特定任务生成笔记工具使用说明。"""
-
     tags_list = ["deep_research", f"task_{task.id}"]
     tags_literal = json.dumps(tags_list, ensure_ascii=False)
 
     if task.note_id:
         read_payload = json.dumps({"action": "read", "note_id": task.note_id}, ensure_ascii=False)
-        update_payload = json.dumps(
+        # 只提供更新笔记的模板,让 LLM 自行填充实际研究内容
+        update_template = json.dumps(
             {
                 "action": "update",
                 "note_id": task.note_id,
@@ -23,7 +23,7 @@ def build_note_guidance(task: TodoItem) -> str:
                 "title": f"任务 {task.id}: {task.title}",
                 "note_type": "task_state",
                 "tags": tags_list,
-                "content": "请将本轮新增信息补充到任务概览中",
+                "content": "<请在此填写更新后的完整内容>",
             },
             ensure_ascii=False,
         )
@@ -32,27 +32,33 @@ def build_note_guidance(task: TodoItem) -> str:
             "笔记协作指引:\n"
             f"- 当前任务笔记 ID:{task.note_id}。\n"
             f"- 在书写总结前必须调用:[TOOL_CALL:note:{read_payload}] 获取最新内容。\n"
-            f"- 完成分析后调用:[TOOL_CALL:note:{update_payload}] 同步增量信息。\n"
+            f"- 完成分析后更新笔记,参数模板如下(需将 content 替换为实际内容):\n"
+            f"  {update_template}\n"
+            "- **重要**:content 字段必须包含原有内容加上本轮新增的研究发现,不要使用占位文本。\n"
             "- 更新时保持原有段落结构,新增内容请在对应段落中补充。\n"
             f"- 建议 tags 保持为 {tags_literal},保证其他 Agent 可快速定位。\n"
             "- 成功同步到笔记后,再输出面向用户的总结。\n"
         )
 
-    create_payload = json.dumps(
+    # 只提供创建笔记的模板,让 LLM 自行填充实际研究内容
+    create_template = json.dumps(
         {
             "action": "create",
             "task_id": task.id,
             "title": f"任务 {task.id}: {task.title}",
             "note_type": "task_state",
             "tags": tags_list,
-            "content": "请记录任务概览、来源概览",
+            "content": "<请在此填写任务总结内容>",
         },
         ensure_ascii=False,
     )
 
     return (
         "笔记协作指引:\n"
-        f"- 当前任务尚未建立笔记,请先调用:[TOOL_CALL:note:{create_payload}]。\n"
+        f"- 当前任务尚未建立笔记。\n"
+        f"- 创建笔记时请使用格式:[TOOL_CALL:note:{{...}}],参数模板如下(需将 content 替换为实际研究总结):\n"
+        f"  {create_template}\n"
+        "- **重要**:content 字段必须填写本次任务的实际研究发现和关键信息,不要使用占位文本。\n"
         "- 创建成功后记录返回的 note_id,并在后续所有更新中复用。\n"
         "- 同步笔记后,再输出面向用户的总结。\n"
     )

+ 8 - 13
Co-creation-projects/JJason-DeepCastAgent/backend/src/services/planner.py

@@ -5,12 +5,12 @@ from __future__ import annotations
 import json
 import logging
 import re
-from typing import Any, List, Optional
+from typing import Any
 
 from hello_agents import ToolAwareSimpleAgent
 
-from models import SummaryState, TodoItem
 from config import Configuration
+from models import SummaryState, TodoItem
 from prompts import get_current_date, todo_planner_instructions
 from utils import strip_thinking_tokens
 
@@ -28,7 +28,7 @@ class PlanningService:
         self._agent = planner_agent
         self._config = config
 
-    def plan_todo_list(self, state: SummaryState) -> List[TodoItem]:
+    def plan_todo_list(self, state: SummaryState) -> list[TodoItem]:
         """
         要求规划器代理将主题分解为可操作的任务。
         
@@ -38,7 +38,6 @@ class PlanningService:
         Returns:
             规划出的 TodoItem 列表。
         """
-
         prompt = todo_planner_instructions.format(
             current_date=get_current_date(),
             research_topic=state.research_topic,
@@ -50,7 +49,7 @@ class PlanningService:
         logger.info("Planner raw output (truncated): %s", response[:500])
 
         tasks_payload = self._extract_tasks(response)
-        todo_items: List[TodoItem] = []
+        todo_items: list[TodoItem] = []
 
         for idx, item in enumerate(tasks_payload, start=1):
             title = str(item.get("title") or f"任务{idx}").strip()
@@ -81,7 +80,6 @@ class PlanningService:
         
         当 LLM 无法生成有效的 JSON 任务列表时调用。
         """
-
         return TodoItem(
             id=1,
             title="基础背景梳理",
@@ -92,19 +90,18 @@ class PlanningService:
     # ------------------------------------------------------------------
     # 解析助手
     # ------------------------------------------------------------------
-    def _extract_tasks(self, raw_response: str) -> List[dict[str, Any]]:
+    def _extract_tasks(self, raw_response: str) -> list[dict[str, Any]]:
         """
         将规划器输出解析为任务字典列表。
         
         支持纯 JSON 格式或嵌入在工具调用中的 JSON。
         """
-
         text = raw_response.strip()
         if self._config.strip_thinking_tokens:
             text = strip_thinking_tokens(text)
 
         json_payload = self._extract_json_payload(text)
-        tasks: List[dict[str, Any]] = []
+        tasks: list[dict[str, Any]] = []
 
         if isinstance(json_payload, dict):
             candidate = json_payload.get("tasks")
@@ -126,9 +123,8 @@ class PlanningService:
 
         return tasks
 
-    def _extract_json_payload(self, text: str) -> Optional[dict[str, Any] | list]:
+    def _extract_json_payload(self, text: str) -> dict[str, Any] | list | None:
         """尝试从文本中定位并解析 JSON 对象或数组。"""
-
         start = text.find("{")
         end = text.rfind("}")
         if start != -1 and end != -1 and end > start:
@@ -149,9 +145,8 @@ class PlanningService:
 
         return None
 
-    def _extract_tool_payload(self, text: str) -> Optional[dict[str, Any]]:
+    def _extract_tool_payload(self, text: str) -> dict[str, Any] | None:
         """解析输出中的第一个 TOOL_CALL 表达式。"""
-
         match = TOOL_CALL_PATTERN.search(text)
         if not match:
             return None

+ 18 - 11
Co-creation-projects/JJason-DeepCastAgent/backend/src/services/reporter.py

@@ -6,30 +6,31 @@ import json
 
 from hello_agents import ToolAwareSimpleAgent
 
-from models import SummaryState
 from config import Configuration
-from utils import strip_thinking_tokens
+from models import SummaryState
 from services.text_processing import strip_tool_calls
+from utils import strip_thinking_tokens
 
 
 class ReportingService:
     """生成最终的结构化报告。"""
 
-    def __init__(self, report_agent: ToolAwareSimpleAgent, config: Configuration) -> None:
+    def __init__(  # noqa: D107
+        self, report_agent: ToolAwareSimpleAgent, config: Configuration
+    ) -> None:
         self._agent = report_agent
         self._config = config
 
     def generate_report(self, state: SummaryState) -> str:
         """
         基于完成的任务生成结构化报告。
-        
+
         Args:
             state: 包含任务结果和笔记的研究状态。
-            
+
         Returns:
             Markdown 格式的报告文本。
         """
-
         tasks_block = []
         for task in state.todo_items:
             summary_block = task.summary or "暂无可用信息"
@@ -50,16 +51,21 @@ class ReportingService:
                     f"- 任务 {task.id}《{task.title}》:note_id={task.note_id}"
                 )
 
-        notes_section = "\n".join(note_references) if note_references else "- 暂无可用任务笔记"
+        notes_section = (
+            "\n".join(note_references) if note_references else "- 暂无可用任务笔记"
+        )
 
-        read_template = json.dumps({"action": "read", "note_id": "<note_id>"}, ensure_ascii=False)
+        read_template = json.dumps(
+            {"action": "read", "note_id": "<note_id>"}, ensure_ascii=False
+        )
+        # 结论笔记模板,让 LLM 自己填充实际内容
         create_conclusion_template = json.dumps(
             {
                 "action": "create",
                 "title": f"研究报告:{state.research_topic}",
                 "note_type": "conclusion",
                 "tags": ["deep_research", "report"],
-                "content": "请在此沉淀最终报告要点",
+                "content": "<请在此填写报告核心要点>",
             },
             ensure_ascii=False,
         )
@@ -69,7 +75,9 @@ class ReportingService:
             f"任务概览:\n{''.join(tasks_block)}\n"
             f"可用任务笔记:\n{notes_section}\n"
             f"请针对每条任务笔记使用格式:[TOOL_CALL:note:{read_template}] 读取内容,整合所有信息后撰写报告。\n"
-            f"如需输出汇总结论,可追加调用:[TOOL_CALL:note:{create_conclusion_template}] 保存报告要点。"
+            f"如需输出汇总结论,可追加调用 note 工具保存报告要点,参数模板如下(需将 content 替换为实际报告要点):\n"
+            f"  {create_conclusion_template}\n"
+            "**重要**:content 字段必须填写本次研究的实际核心发现和结论,不要使用占位文本。"
         )
 
         response = self._agent.run(prompt)
@@ -82,4 +90,3 @@ class ReportingService:
         report_text = strip_tool_calls(report_text).strip()
 
         return report_text or "报告生成失败,请检查输入。"
-

+ 82 - 129
Co-creation-projects/JJason-DeepCastAgent/backend/src/services/script_generator.py

@@ -4,161 +4,114 @@ from __future__ import annotations
 
 import json
 import logging
-import re
-from typing import Any, List
 
-from hello_agents import ToolAwareSimpleAgent
+from openai import OpenAI
 
-from models import SummaryState
 from config import Configuration
-from utils import strip_thinking_tokens
+from models import SummaryState
+from prompts import script_writer_instructions
 
 logger = logging.getLogger(__name__)
 
+# 播客脚本的 JSON Schema
+SCRIPT_JSON_SCHEMA = {
+    "type": "array",
+    "items": {
+        "type": "object",
+        "properties": {
+            "role": {
+                "type": "string",
+                "enum": ["Host", "Guest"],
+                "description": "对话角色,Host 为主持人,Guest 为嘉宾"
+            },
+            "content": {
+                "type": "string",
+                "description": "对话内容"
+            }
+        },
+        "required": ["role", "content"]
+    },
+    "minItems": 6,
+    "maxItems": 15
+}
+
 
 class ScriptGenerationService:
-    """从研究报告生成对话脚本。"""
+    """从研究报告生成对话脚本(使用结构化输出)。"""
 
-    def __init__(self, script_agent: ToolAwareSimpleAgent, config: Configuration) -> None:
-        self._agent = script_agent
+    def __init__(self, script_agent, config: Configuration) -> None:
+        """初始化服务。"""
         self._config = config
+        # 直接使用 OpenAI 客户端以支持结构化输出
+        self._client = OpenAI(
+            api_key=config.llm_api_key,
+            base_url=config.llm_base_url,
+        )
+        # 使用 fast_llm_model(ecnu-plus)进行脚本生成,它支持结构化输出
+        self._model = config.fast_llm_model or "ecnu-plus"
 
-    def generate_script(self, state: SummaryState) -> List[dict[str, str]]:
-        """
-        基于结构化报告生成播客脚本。
-        
-        Args:
-            state: 包含结构化报告的研究状态。
-            
-        Returns:
-            对话脚本列表,每项包含 role 和 content。
-        """
-
+    def generate_script(self, state: SummaryState) -> list[dict[str, str]]:
+        """基于结构化报告生成播客脚本(使用结构化输出)。"""
         if not state.structured_report:
             logger.warning("No structured report available for script generation.")
             return []
         
-        # 记录报告长度
-        report_length = len(state.structured_report) if state.structured_report else 0
-        logger.info("Generating script from report (%d chars)...", report_length)
-
-        prompt = f"<RESEARCH_REPORT>\n{state.structured_report}\n</RESEARCH_REPORT>"
+        report_length = len(state.structured_report)
+        logger.info("Generating script from report (%d chars) using structured output...", report_length)
 
-        response = self._agent.run(prompt)
-        self._agent.clear_history()
-        
-        # 记录原始响应
-        response_length = len(response) if response else 0
-        logger.info("Received LLM response (%d chars)", response_length)
+        user_prompt = f"<RESEARCH_REPORT>\n{state.structured_report}\n</RESEARCH_REPORT>"
 
-        if self._config.strip_thinking_tokens:
-            response = strip_thinking_tokens(response)
-        
-        cleaned_response = response.strip()
-        
-        # 调试日志:记录原始响应的前500字符
-        logger.debug("Raw LLM response (first 500 chars): %s", response[:500] if response else "EMPTY")
-        
-        # 1. 尝试查找 Markdown 代码块
-        code_block_pattern = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
-        match = code_block_pattern.search(cleaned_response)
-        if match:
-            cleaned_response = match.group(1).strip()
-            logger.debug("Extracted from code block: %s", cleaned_response[:200] if cleaned_response else "EMPTY")
-        else:
-            # 2. 尝试查找 [ 和 ] 之间的内容
-            start = cleaned_response.find("[")
-            end = cleaned_response.rfind("]")
-            if start != -1 and end != -1 and end > start:
-                cleaned_response = cleaned_response[start:end+1]
-                logger.debug("Extracted array from response: %s", cleaned_response[:200] if cleaned_response else "EMPTY")
-            else:
-                logger.warning("Could not find JSON array in response. Response preview: %s", cleaned_response[:300] if cleaned_response else "EMPTY")
-        
-        # 3. 修复常见的 JSON 格式问题
-        # 替换单引号为双引号(某些 LLM 可能输出单引号)
-        if cleaned_response and cleaned_response.startswith("["):
-            # 尝试修复可能的格式问题
-            cleaned_response = cleaned_response.replace("'", '"')
-            # 移除可能的尾随逗号
-            cleaned_response = re.sub(r',\s*]', ']', cleaned_response)
-            cleaned_response = re.sub(r',\s*}', '}', cleaned_response)
-        
         try:
-            script = json.loads(cleaned_response)
+            response = self._client.chat.completions.create(
+                model=self._model,
+                messages=[
+                    {"role": "system", "content": script_writer_instructions.strip()},
+                    {"role": "user", "content": user_prompt},
+                ],
+                temperature=0.7,
+                max_tokens=4096,
+                response_format={
+                    "type": "json_schema",
+                    "json_schema": {
+                        "name": "podcast_script",
+                        "schema": SCRIPT_JSON_SCHEMA
+                    },
+                },
+            )
+            
+            content = response.choices[0].message.content
+            logger.info("Received structured response (%d chars)", len(content) if content else 0)
+            
+            if not content:
+                logger.error("Empty response from LLM")
+                return []
+            
+            # 直接解析 JSON(结构化输出保证格式正确)
+            script = json.loads(content)
+            
             if not isinstance(script, list):
-                logger.error("Script generation output is not a list: %s", type(script))
+                logger.error("Script output is not a list: %s", type(script))
                 return []
             
-            # 验证脚本格式
+            # 验证并标准化
             valid_script = []
             for item in script:
                 if isinstance(item, dict) and "role" in item and "content" in item:
-                    valid_script.append(item)
-                elif isinstance(item, dict):
-                    # 尝试兼容其他可能的字段名
-                    role = item.get("role") or item.get("speaker") or item.get("name") or ""
-                    content = item.get("content") or item.get("text") or item.get("message") or ""
-                    if role and content:
-                        valid_script.append({"role": role, "content": content})
+                    role = item["role"]
+                    content = item["content"]
+                    # 标准化角色名
+                    if role.lower() in ["host", "xiayu"]:
+                        role = "Host"
+                    elif role.lower() in ["guest", "liwa"]:
+                        role = "Guest"
+                    valid_script.append({"role": role, "content": content})
             
             logger.info("Generated script with %d dialogue turns.", len(valid_script))
             return valid_script
 
         except json.JSONDecodeError as e:
-            logger.error("Failed to parse script generation output as JSON.")
-            logger.error("JSON error: %s", str(e))
-            logger.error("Cleaned response (first 500 chars): %s", cleaned_response[:500] if cleaned_response else "EMPTY")
-            logger.error("Original response (first 1000 chars): %s", response[:1000] if response else "EMPTY")
-            
-            # 最后尝试:使用正则表达式提取对话
-            return self._fallback_extract_dialogues(response)
-
-    def _fallback_extract_dialogues(self, text: str) -> List[dict[str, str]]:
-        """
-        当 JSON 解析失败时,尝试用正则表达式提取对话内容。
-        
-        支持的格式:
-        - {"role": "Host", "content": "..."}
-        - Host: ...
-        - **Host**: ...
-        """
-        dialogues = []
-        
-        # 方法1:尝试提取单个 JSON 对象
-        json_obj_pattern = re.compile(
-            r'\{\s*"role"\s*:\s*"([^"]+)"\s*,\s*"content"\s*:\s*"([^"]*(?:\\.[^"]*)*)"\s*\}',
-            re.DOTALL
-        )
-        matches = json_obj_pattern.findall(text)
-        if matches:
-            for role, content in matches:
-                # 解码转义字符
-                try:
-                    content = content.encode().decode('unicode_escape')
-                except Exception:
-                    pass
-                dialogues.append({"role": role, "content": content})
-            if dialogues:
-                logger.info("Fallback extraction (JSON objects) found %d dialogues.", len(dialogues))
-                return dialogues
-        
-        # 方法2:尝试提取 "Host: ..." 或 "**Host**: ..." 格式
-        line_pattern = re.compile(
-            r'(?:\*\*)?(?P<role>Host|Guest|Xiayu|Liwa)(?:\*\*)?\s*[::]\s*(?P<content>.+?)(?=(?:\n(?:\*\*)?(?:Host|Guest|Xiayu|Liwa)(?:\*\*)?\s*[::])|$)',
-            re.DOTALL | re.IGNORECASE
-        )
-        matches = line_pattern.findall(text)
-        if matches:
-            for role, content in matches:
-                content = content.strip().strip('"').strip("'")
-                if content:
-                    # 标准化角色名
-                    role_normalized = "Host" if role.lower() in ["host", "xiayu"] else "Guest"
-                    dialogues.append({"role": role_normalized, "content": content})
-            if dialogues:
-                logger.info("Fallback extraction (line format) found %d dialogues.", len(dialogues))
-                return dialogues
-        
-        logger.warning("Fallback extraction failed. No dialogues found.")
-        return []
+            logger.error("JSON decode error (should not happen with structured output): %s", e)
+            return []
+        except Exception as e:
+            logger.error("Script generation failed: %s", e)
+            return []

+ 3 - 3
Co-creation-projects/JJason-DeepCastAgent/backend/src/services/search.py

@@ -3,7 +3,7 @@
 from __future__ import annotations
 
 import logging
-from typing import Any, Optional, Tuple
+from typing import Any
 
 from hello_agents.tools import SearchTool
 
@@ -36,7 +36,7 @@ def dispatch_search(
     query: str,
     config: Configuration,
     loop_count: int,
-) -> Tuple[dict[str, Any] | None, list[str], Optional[str], str]:
+) -> tuple[dict[str, Any] | None, list[str], str | None, str]:
     """
     执行配置的搜索后端并标准化响应负载。
     
@@ -102,7 +102,7 @@ def dispatch_search(
 
 def prepare_research_context(
     search_result: dict[str, Any] | None,
-    answer_text: Optional[str],
+    answer_text: str | None,
     config: Configuration,
 ) -> tuple[str, str]:
     """

+ 5 - 9
Co-creation-projects/JJason-DeepCastAgent/backend/src/services/summarizer.py

@@ -3,21 +3,20 @@
 from __future__ import annotations
 
 from collections.abc import Callable, Iterator
-from typing import Tuple
 
 from hello_agents import ToolAwareSimpleAgent
 
-from models import SummaryState, TodoItem
 from config import Configuration
-from utils import strip_thinking_tokens
+from models import SummaryState, TodoItem
 from services.notes import build_note_guidance
 from services.text_processing import strip_tool_calls
+from utils import strip_thinking_tokens
 
 
 class SummarizationService:
     """处理同步和流式任务总结。"""
 
-    def __init__(
+    def __init__(  # noqa: D107
         self,
         summarizer_factory: Callable[[], ToolAwareSimpleAgent],
         config: Configuration,
@@ -27,7 +26,6 @@ class SummarizationService:
 
     def summarize_task(self, state: SummaryState, task: TodoItem, context: str) -> str:
         """使用总结代理生成特定于任务的总结。"""
-
         prompt = self._build_prompt(state, task, context)
 
         agent = self._agent_factory()
@@ -46,9 +44,8 @@ class SummarizationService:
 
     def stream_task_summary(
         self, state: SummaryState, task: TodoItem, context: str
-    ) -> Tuple[Iterator[str], Callable[[], str]]:
+    ) -> tuple[Iterator[str], Callable[[], str]]:
         """流式传输任务的总结文本,同时收集完整输出。"""
-
         prompt = self._build_prompt(state, task, context)
         remove_thinking = self._config.strip_thinking_tokens
         raw_buffer = ""
@@ -60,7 +57,7 @@ class SummarizationService:
             """
             处理缓冲区,提取并 yield 所有不在 <think>...</think> 块中的可见文本。
             如果遇到不完整的 <think> 标签,会暂停输出等待更多数据。
-            """
+            """  # noqa: D205
             nonlocal emit_index, raw_buffer
             while True:
                 start = raw_buffer.find("<think>", emit_index)
@@ -117,7 +114,6 @@ class SummarizationService:
 
     def _build_prompt(self, state: SummaryState, task: TodoItem, context: str) -> str:
         """构建两种模式共享的总结提示。"""
-
         return (
             f"任务主题:{state.research_topic}\n"
             f"任务名称:{task.title}\n"

+ 0 - 1
Co-creation-projects/JJason-DeepCastAgent/backend/src/services/text_processing.py

@@ -7,7 +7,6 @@ import re
 
 def strip_tool_calls(text: str) -> str:
     """移除文本中的工具调用标记。"""
-
     if not text:
         return text
 

+ 12 - 17
Co-creation-projects/JJason-DeepCastAgent/backend/src/services/tool_events.py

@@ -4,10 +4,11 @@ from __future__ import annotations
 
 import logging
 import re
+from collections.abc import Callable
 from dataclasses import dataclass
 from pathlib import Path
 from threading import Lock
-from typing import Any, Callable, Optional
+from typing import Any
 
 from models import SummaryState, TodoItem
 
@@ -24,19 +25,19 @@ class ToolCallEvent:
     raw_parameters: str
     parsed_parameters: dict[str, Any]
     result: str
-    task_id: Optional[int]
-    note_id: Optional[str]
+    task_id: int | None
+    note_id: str | None
 
 
 class ToolCallTracker:
     """收集工具调用事件并将其转换为 SSE 负载。"""
 
-    def __init__(self, notes_workspace: Optional[str]) -> None:
+    def __init__(self, notes_workspace: str | None) -> None:
         self._notes_workspace = notes_workspace
         self._events: list[ToolCallEvent] = []
         self._cursor = 0
         self._lock = Lock()
-        self._event_sink: Optional[Callable[[dict[str, Any]], None]] = None
+        self._event_sink: Callable[[dict[str, Any]], None] | None = None
 
     def record(self, payload: dict[str, Any]) -> None:
         """
@@ -45,7 +46,6 @@ class ToolCallTracker:
         Args:
             payload: 工具调用事件负载,包含工具名、参数和结果。
         """
-
         agent_name = str(payload.get("agent_name") or "unknown")
         tool_name = str(payload.get("tool_name") or "unknown")
         raw_parameters = str(payload.get("raw_parameters") or "")
@@ -56,7 +56,7 @@ class ToolCallTracker:
             parsed_parameters = {}
 
         task_id = self._infer_task_id(parsed_parameters)
-        note_id: Optional[str] = None
+        note_id: str | None = None
 
         if tool_name == "note":
             note_id = parsed_parameters.get("note_id")
@@ -93,7 +93,7 @@ class ToolCallTracker:
     # ------------------------------------------------------------------
     # 排放助手
     # ------------------------------------------------------------------
-    def drain(self, state: SummaryState, *, step: Optional[int] = None) -> list[dict[str, Any]]:
+    def drain(self, state: SummaryState, *, step: int | None = None) -> list[dict[str, Any]]:
         """
         提取尚未消费的工具调用事件,并同步任务的 note_id。
         
@@ -107,7 +107,6 @@ class ToolCallTracker:
         Returns:
             准备发送给前端的事件字典列表。
         """
-
         with self._lock:
             if self._cursor >= len(self._events):
                 return []
@@ -146,7 +145,6 @@ class ToolCallTracker:
         Returns:
             包含所有工具调用事件的字典列表。
         """
-
         with self._lock:
             return [
                 {
@@ -162,17 +160,16 @@ class ToolCallTracker:
                 for event in self._events
             ]
 
-    def set_event_sink(self, sink: Optional[Callable[[dict[str, Any]], None]]) -> None:
+    def set_event_sink(self, sink: Callable[[dict[str, Any]], None] | None) -> None:
         """
         注册一个回调以获取即时工具事件通知。
         
         Args:
             sink: 接收事件字典的回调函数。
         """
-
         self._event_sink = sink
 
-    def _build_payload(self, event: ToolCallEvent, step: Optional[int]) -> dict[str, Any]:
+    def _build_payload(self, event: ToolCallEvent, step: int | None) -> dict[str, Any]:
         payload = {
             "type": "tool_call",
             "event_id": event.id,
@@ -195,7 +192,6 @@ class ToolCallTracker:
     # ------------------------------------------------------------------
     def _attach_note_to_task(self, tasks: list[TodoItem], task_id: int, note_id: str) -> None:
         """使用笔记元数据更新匹配的 TODO 项目。"""
-
         for task in tasks:
             if task.id != task_id:
                 continue
@@ -208,9 +204,8 @@ class ToolCallTracker:
                 task.note_path = str(Path(self._notes_workspace) / f"{note_id}.md")
             break
 
-    def _infer_task_id(self, parameters: dict[str, Any]) -> Optional[int]:
+    def _infer_task_id(self, parameters: dict[str, Any]) -> int | None:
         """尝试从工具参数推断 task_id。"""
-
         if not parameters:
             return None
 
@@ -235,7 +230,7 @@ class ToolCallTracker:
 
         return None
 
-    def _extract_note_id(self, response: str) -> Optional[str]:
+    def _extract_note_id(self, response: str) -> str | None:
         if not response:
             return None
 

+ 5 - 9
Co-creation-projects/JJason-DeepCastAgent/backend/src/utils.py

@@ -3,7 +3,7 @@
 from __future__ import annotations
 
 import logging
-from typing import Any, Dict, List, Union
+from typing import Any
 
 CHARS_PER_TOKEN = 4
 
@@ -12,13 +12,11 @@ logger = logging.getLogger(__name__)
 
 def get_config_value(value: Any) -> str:
     """以纯字符串形式返回配置值。"""
-
     return value if isinstance(value, str) else value.value
 
 
 def strip_thinking_tokens(text: str) -> str:
     """移除模型响应中的 ``<think>`` 部分。"""
-
     while "<think>" in text and "</think>" in text:
         start = text.find("<think>")
         end = text.find("</think>") + len("</think>")
@@ -27,7 +25,7 @@ def strip_thinking_tokens(text: str) -> str:
 
 
 def deduplicate_and_format_sources(
-    search_response: Dict[str, Any] | List[Dict[str, Any]],
+    search_response: dict[str, Any] | list[dict[str, Any]],
     max_tokens_per_source: int,
     *,
     fetch_full_page: bool = False,
@@ -43,13 +41,12 @@ def deduplicate_and_format_sources(
     Returns:
         格式化后的上下文文本字符串。
     """
-
     if isinstance(search_response, dict):
         sources_list = search_response.get("results", [])
     else:
         sources_list = search_response
 
-    unique_sources: dict[str, Dict[str, Any]] = {}
+    unique_sources: dict[str, dict[str, Any]] = {}
     for source in sources_list:
         url = source.get("url")
         if not url:
@@ -57,7 +54,7 @@ def deduplicate_and_format_sources(
         if url not in unique_sources:
             unique_sources[url] = source
 
-    formatted_parts: List[str] = []
+    formatted_parts: list[str] = []
     for source in unique_sources.values():
         title = source.get("title") or source.get("url", "")
         content = source.get("content", "")
@@ -80,9 +77,8 @@ def deduplicate_and_format_sources(
     return "".join(formatted_parts).strip()
 
 
-def format_sources(search_results: Dict[str, Any] | None) -> str:
+def format_sources(search_results: dict[str, Any] | None) -> str:
     """返回总结搜索来源的项目符号列表。"""
-
     if not search_results:
         return ""
 

+ 421 - 292
Co-creation-projects/JJason-DeepCastAgent/frontend/src/App.vue

@@ -47,12 +47,16 @@
       <!-- 2. Production View: 制作进度监控 -->
       <section v-else-if="currentView === 'producing'" class="view-production" key="production">
         <div class="production-content">
+          <!-- 顶部:标题和控制 -->
           <header class="production-header">
-            <h2>正在制作您的播客</h2>
-            <p class="production-topic">「{{ form.topic }}」</p>
+            <div class="header-left">
+              <h2>正在制作您的播客</h2>
+              <p class="production-topic">「{{ form.topic }}」</p>
+            </div>
             <button class="cancel-btn" @click="cancelProduction">取消</button>
           </header>
 
+          <!-- 阶段进度指示器 -->
           <div class="stage-monitor">
             <div class="stage-step" :class="{ active: productionStage === 'research', completed: isStageCompleted('research') }">
               <div class="step-icon">🔍</div>
@@ -101,18 +105,59 @@
               </div>
             </div>
           </div>
+
+          <!-- 报告预览区(在日志下方) -->
+          <div v-if="reportReady" class="report-section">
+            <div class="section-header">
+              <h3>📄 深度研究报告</h3>
+              <button class="action-btn" @click="downloadReport">
+                ⬇️ 下载报告
+              </button>
+            </div>
+            <div class="report-content-box">
+              <div class="markdown-report" v-html="md.render(reportMarkdown)"></div>
+            </div>
+          </div>
+
+          <!-- 播客完成区 -->
+          <div v-if="podcastReady" class="podcast-section">
+            <div class="podcast-ready-card">
+              <div class="ready-icon">🎉</div>
+              <h3>播客制作完成!</h3>
+              <p>您的播客音频已生成完毕</p>
+              
+              <!-- 简单音频播放器 -->
+              <div class="simple-player">
+                <audio 
+                  ref="audioPlayer" 
+                  :src="audioUrl" 
+                  controls
+                  @play="isPlaying = true"
+                  @pause="isPlaying = false"
+                ></audio>
+              </div>
+              
+              <div class="podcast-actions">
+                <a :href="audioUrl" download class="download-podcast-btn">
+                  ⬇️ 下载 MP3
+                </a>
+                <button class="new-podcast-btn" @click="resetApp">
+                  制作新播客
+                </button>
+              </div>
+            </div>
+          </div>
         </div>
       </section>
 
-      <!-- 3. Player View: 播放器与脚本 -->
+      <!-- 3. Player View: 独立播放器页面 -->
       <section v-else-if="currentView === 'player'" class="view-player" key="player">
-        <div class="player-layout">
-          <!-- Left: Player Control -->
-          <div class="player-sidebar">
-            <button class="back-home-btn" @click="resetApp">
-              ← 制作新播客
-            </button>
-            
+        <div class="player-container">
+          <button class="back-home-btn" @click="resetApp">
+            ← 制作新播客
+          </button>
+          
+          <div class="player-card">
             <div class="album-art">
               <div class="vinyl-record" :class="{ spinning: isPlaying }">
                 <div class="vinyl-label">DC</div>
@@ -124,59 +169,29 @@
               <p>DeepCast 原创播客</p>
             </div>
 
-            <div class="audio-controls">
+            <!-- 简单原生播放器 -->
+            <div class="simple-player-large">
               <audio 
                 ref="audioPlayer" 
                 :src="audioUrl" 
-                @timeupdate="onTimeUpdate"
-                @ended="isPlaying = false"
+                controls
                 @play="isPlaying = true"
                 @pause="isPlaying = false"
               ></audio>
-              
-              <div class="control-buttons">
-                <button class="play-btn" @click="togglePlay">
-                  {{ isPlaying ? '⏸' : '▶' }}
-                </button>
-                <a :href="audioUrl" download class="download-btn" title="下载 MP3">
-                  ⬇
-                </a>
-              </div>
-              
-              <div class="progress-bar-wrapper" @click="seekAudio">
-                <div class="progress-bar-bg">
-                  <div class="progress-bar-fill" :style="{ width: progressPercent + '%' }"></div>
-                </div>
-                <div class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</div>
-              </div>
             </div>
 
-            <div class="report-toggle">
-              <button @click="showReport = !showReport">
-                {{ showReport ? '隐藏深度研究报告' : '查看深度研究报告' }}
-              </button>
-            </div>
+            <a :href="audioUrl" download class="download-btn-large">
+              ⬇️ 下载 MP3
+            </a>
           </div>
 
-          <!-- Right: Script / Report -->
-          <div class="content-main">
-            <div v-if="!showReport" class="script-chat">
-              <div 
-                v-for="(line, idx) in podcastScript" 
-                :key="idx" 
-                class="chat-bubble"
-                :class="line.role.toLowerCase()"
-              >
-                <div class="avatar">{{ line.role[0] }}</div>
-                <div class="bubble-content">
-                  <div class="speaker-name">{{ line.role }}</div>
-                  <p>{{ line.content }}</p>
-                </div>
-              </div>
-            </div>
-
-            <div v-else class="markdown-report">
-              <div class="report-content" v-html="md.render(reportMarkdown)"></div>
+          <!-- 报告查看区 -->
+          <div class="report-toggle-section">
+            <button class="toggle-btn" @click="showReport = !showReport">
+              {{ showReport ? '🔼 收起研究报告' : '🔽 查看研究报告' }}
+            </button>
+            <div v-if="showReport" class="report-panel">
+              <div class="markdown-report" v-html="md.render(reportMarkdown)"></div>
             </div>
           </div>
         </div>
@@ -220,7 +235,9 @@ const isPlaying = ref(false);
 const currentTime = ref(0);
 const duration = ref(0);
 const progressPercent = computed(() => (duration.value ? (currentTime.value / duration.value) * 100 : 0));
-const showReport = ref(false);
+const showReport = ref(true); // 默认显示报告
+const reportReady = ref(false); // 报告是否已生成
+const podcastReady = ref(false); // 播客是否已生成
 
 // Research Progress State
 const totalTasks = ref(0);
@@ -343,6 +360,9 @@ async function startProduction() {
   audioProgress.total = 0;
   audioProgress.role = "";
   currentStatusMessage.value = "正在初始化...";
+  reportReady.value = false;
+  podcastReady.value = false;
+  showReport.value = true;
 
   abortController = new AbortController();
   
@@ -531,10 +551,11 @@ function handleStreamEvent(event: ResearchStreamEvent) {
       }
   }
 
-  // 5. Report Ready
+  // 5. Report Ready - 显示报告预览
   if (event.type === "final_report") {
     reportMarkdown.value = String(event.report);
-    currentStatusMessage.value = "深度研究报告已完成";
+    reportReady.value = true;
+    currentStatusMessage.value = "深度研究报告已完成,继续生成播客...";
     const reportLen = String(event.report).length;
     addLog(`📄 [REPORT] status=completed length=${reportLen} chars`);
   }
@@ -589,20 +610,17 @@ function handleStreamEvent(event: ResearchStreamEvent) {
     }
   }
 
-  // 9. Podcast Ready (Final)
+  // 9. Podcast Ready (Final) - 设置播客就绪状态
   if (event.type === "podcast_ready") {
     const payload = event as any;
     const filename = String(payload.file).split(/[\\/]/).pop();
     if (filename) {
       const baseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
       audioUrl.value = `${baseUrl}/output/${filename}`;
+      podcastReady.value = true;
       currentStatusMessage.value = "🎉 播客制作完成!";
       addLog(`🎉 [PODCAST] status=ready file=${filename}`);
       productionStage.value = "done";
-      
-      setTimeout(() => {
-        currentView.value = "player";
-      }, 1500);
     }
   }
 
@@ -636,6 +654,27 @@ function resetApp() {
   isPlaying.value = false;
   currentStatusMessage.value = "";
   stopWaitingAnimation();
+  reportReady.value = false;
+  podcastReady.value = false;
+}
+
+// 下载报告为 Markdown 文件
+function downloadReport() {
+  if (!reportMarkdown.value) return;
+  const blob = new Blob([reportMarkdown.value], { type: 'text/markdown;charset=utf-8' });
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement('a');
+  a.href = url;
+  a.download = `${form.topic.slice(0, 30) || 'report'}_研究报告.md`;
+  document.body.appendChild(a);
+  a.click();
+  document.body.removeChild(a);
+  URL.revokeObjectURL(url);
+}
+
+// 切换到播放器视图
+function playPodcast() {
+  currentView.value = "player";
 }
 
 // Audio Controls
@@ -850,13 +889,232 @@ select {
   cursor: not-allowed;
 }
 
-/* --- Production View --- */
+/* --- Production View (上下布局) --- */
 .view-production {
   overflow-y: auto;
   width: 100%;
   display: block;
 }
 
+.production-content {
+  max-width: 900px;
+  margin: 0 auto;
+  padding: 2rem;
+  display: flex;
+  flex-direction: column;
+  gap: 1.5rem;
+}
+
+.production-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 1rem;
+}
+
+.header-left h2 {
+  font-size: 1.5rem;
+  margin: 0;
+}
+
+.production-topic {
+  color: #94a3b8;
+  font-size: 1rem;
+  margin-top: 0.25rem;
+  font-style: italic;
+}
+
+.cancel-btn {
+  background: transparent;
+  border: 1px solid rgba(239, 68, 68, 0.5);
+  color: #fca5a5;
+  padding: 0.5rem 1rem;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.3s ease;
+}
+
+.cancel-btn:hover {
+  background: rgba(239, 68, 68, 0.1);
+  border-color: rgba(239, 68, 68, 0.8);
+}
+
+/* 报告区块 */
+.report-section {
+  background: rgba(15, 23, 42, 0.8);
+  border: 1px solid rgba(255, 255, 255, 0.1);
+  border-radius: 12px;
+  overflow: hidden;
+}
+
+.section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 1rem 1.5rem;
+  background: rgba(30, 41, 59, 0.8);
+  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.section-header h3 {
+  margin: 0;
+  color: #f1f5f9;
+  font-size: 1.1rem;
+}
+
+.action-btn {
+  padding: 0.5rem 1rem;
+  background: linear-gradient(135deg, #3b82f6, #8b5cf6);
+  border: none;
+  border-radius: 8px;
+  color: white;
+  font-size: 0.9rem;
+  cursor: pointer;
+  transition: all 0.3s ease;
+}
+
+.action-btn:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 4px 15px rgba(139, 92, 246, 0.4);
+}
+
+.report-content-box {
+  padding: 1.5rem;
+  max-height: 500px;
+  overflow-y: auto;
+}
+
+.report-content-box .markdown-report {
+  font-size: 0.9rem;
+  line-height: 1.7;
+}
+
+/* 播客完成区块 */
+.podcast-section {
+  margin-top: 1rem;
+}
+
+.podcast-ready-card {
+  background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(59, 130, 246, 0.2));
+  border: 1px solid rgba(16, 185, 129, 0.3);
+  border-radius: 16px;
+  padding: 2rem;
+  text-align: center;
+}
+
+.ready-icon {
+  font-size: 3rem;
+  margin-bottom: 1rem;
+}
+
+.podcast-ready-card h3 {
+  color: #10b981;
+  font-size: 1.5rem;
+  margin-bottom: 0.5rem;
+}
+
+.podcast-ready-card p {
+  color: #94a3b8;
+  margin-bottom: 1rem;
+}
+
+/* 简单音频播放器 */
+.simple-player {
+  margin: 1.5rem auto;
+  max-width: 400px;
+}
+
+.simple-player audio {
+  width: 100%;
+  height: 50px;
+  border-radius: 8px;
+}
+
+.podcast-actions {
+  display: flex;
+  gap: 1rem;
+  justify-content: center;
+  margin-top: 1.5rem;
+}
+
+.download-podcast-btn {
+  padding: 0.75rem 2rem;
+  background: linear-gradient(135deg, #10b981, #059669);
+  border: none;
+  border-radius: 10px;
+  color: white;
+  font-size: 1rem;
+  font-weight: 600;
+  text-decoration: none;
+  cursor: pointer;
+  transition: all 0.3s ease;
+}
+
+.download-podcast-btn:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);
+}
+
+.new-podcast-btn {
+  padding: 0.75rem 2rem;
+  background: transparent;
+  border: 1px solid rgba(255, 255, 255, 0.2);
+  border-radius: 10px;
+  color: #94a3b8;
+  font-size: 1rem;
+  cursor: pointer;
+  transition: all 0.3s ease;
+}
+
+.new-podcast-btn:hover {
+  border-color: rgba(255, 255, 255, 0.4);
+  color: #f1f5f9;
+}
+
+/* 等待报告 */
+.waiting-report {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 4rem 2rem;
+  background: rgba(15, 23, 42, 0.5);
+  border: 1px dashed rgba(255, 255, 255, 0.2);
+  border-radius: 12px;
+  text-align: center;
+}
+
+.waiting-icon {
+  font-size: 3rem;
+  margin-bottom: 1rem;
+  animation: bounce 2s infinite;
+}
+
+@keyframes bounce {
+  0%, 100% { transform: translateY(0); }
+  50% { transform: translateY(-10px); }
+}
+
+.waiting-report p {
+  color: #94a3b8;
+  font-size: 1rem;
+}
+
+/* 响应式布局 */
+@media (max-width: 1024px) {
+  .production-layout {
+    flex-direction: column;
+  }
+  
+  .production-sidebar {
+    flex: none;
+  }
+  
+  .report-preview-section {
+    max-height: 400px;
+  }
+}
+
 .production-content {
   max-width: 800px;
   margin: 0 auto;
@@ -1274,46 +1532,43 @@ select {
   line-height: 1.5;
 }
 
-/* --- Player View --- */
+/* --- Player View (简化版) --- */
 .view-player {
-  padding: 0;
-  overflow: hidden; /* Player view handles internal scrolling */
-}
-
-.player-layout {
-  display: flex;
-  height: 100%;
-  width: 100%;
+  padding: 2rem;
+  overflow-y: auto;
 }
 
-.player-sidebar {
-  width: 400px;
-  background: #0f172a;
-  border-right: 1px solid #1e293b;
-  padding: 2rem;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  z-index: 2;
+.player-container {
+  max-width: 800px;
+  margin: 0 auto;
 }
 
 .back-home-btn {
-  align-self: flex-start;
   background: none;
   border: none;
   color: #64748b;
   cursor: pointer;
   margin-bottom: 2rem;
+  font-size: 1rem;
 }
 
 .back-home-btn:hover {
   color: #fff;
 }
 
-.album-art {
-  width: 260px;
-  height: 260px;
+.player-card {
+  background: rgba(30, 41, 59, 0.5);
+  border: 1px solid rgba(255, 255, 255, 0.1);
+  border-radius: 16px;
+  padding: 2rem;
+  text-align: center;
   margin-bottom: 2rem;
+}
+
+.album-art {
+  width: 200px;
+  height: 200px;
+  margin: 0 auto 1.5rem;
   position: relative;
 }
 
@@ -1330,10 +1585,10 @@ select {
 }
 
 .vinyl-record.spinning {
-  animation: spin 5s linear infinite;
+  animation: vinylSpin 5s linear infinite;
 }
 
-@keyframes spin {
+@keyframes vinylSpin {
   from { transform: rotate(0deg); }
   to { transform: rotate(360deg); }
 }
@@ -1353,7 +1608,7 @@ select {
 
 .track-info {
   text-align: center;
-  margin-bottom: 2rem;
+  margin-bottom: 1.5rem;
 }
 
 .track-info h3 {
@@ -1364,161 +1619,69 @@ select {
   color: #fff;
 }
 
-.audio-controls {
-  width: 100%;
-  display: flex;
-  flex-direction: column;
-  gap: 1.5rem;
-}
-
-.control-buttons {
-  display: flex;
-  justify-content: center;
-  gap: 1.5rem;
-  align-items: center;
+.track-info p {
+  color: #94a3b8;
+  font-size: 0.9rem;
 }
 
-.play-btn {
-  width: 64px;
-  height: 64px;
-  border-radius: 50%;
-  background: #fff;
-  color: #0f172a;
-  border: none;
-  font-size: 1.5rem;
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  transition: transform 0.1s;
+/* 简化的大播放器 */
+.simple-player-large {
+  margin: 1.5rem 0;
 }
 
-.play-btn:active {
-  transform: scale(0.95);
+.simple-player-large audio {
+  width: 100%;
+  height: 50px;
+  border-radius: 8px;
 }
 
-.download-btn {
-  width: 40px;
-  height: 40px;
-  border-radius: 50%;
-  background: rgba(255,255,255,0.1);
-  color: #fff;
-  display: flex;
-  align-items: center;
-  justify-content: center;
+.download-btn-large {
+  display: inline-block;
+  padding: 0.75rem 2rem;
+  background: linear-gradient(135deg, #3b82f6, #8b5cf6);
+  border-radius: 10px;
+  color: white;
+  font-size: 1rem;
+  font-weight: 600;
   text-decoration: none;
-  font-size: 1.2rem;
-}
-
-.progress-bar-wrapper {
-  cursor: pointer;
-  padding: 10px 0;
-}
-
-.progress-bar-bg {
-  width: 100%;
-  height: 4px;
-  background: #334155;
-  border-radius: 2px;
-  position: relative;
+  transition: all 0.3s ease;
 }
 
-.progress-bar-fill {
-  height: 100%;
-  background: #60a5fa;
-  border-radius: 2px;
+.download-btn-large:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4);
 }
 
-.time-display {
-  font-size: 0.75rem;
-  color: #64748b;
-  margin-top: 0.5rem;
-  text-align: right;
+/* 报告切换区 */
+.report-toggle-section {
+  margin-top: 2rem;
 }
 
-.report-toggle {
-  margin-top: auto;
+.toggle-btn {
   width: 100%;
-  text-align: center;
-}
-
-.report-toggle button {
-  background: none;
-  border: 1px solid #334155;
+  padding: 1rem;
+  background: rgba(30, 41, 59, 0.5);
+  border: 1px solid rgba(255, 255, 255, 0.1);
+  border-radius: 8px;
   color: #94a3b8;
-  padding: 0.5rem 1rem;
-  border-radius: 20px;
-  font-size: 0.8rem;
+  font-size: 1rem;
   cursor: pointer;
+  transition: all 0.3s ease;
 }
 
-.content-main {
-  flex: 1;
-  background: #1e293b;
-  padding: 2rem;
-  overflow-y: auto;
-}
-
-/* Chat UI */
-.script-chat {
-  max-width: 800px;
-  margin: 0 auto;
-  display: flex;
-  flex-direction: column;
-  gap: 1.5rem;
-}
-
-.chat-bubble {
-  display: flex;
-  gap: 1rem;
-}
-
-.chat-bubble.host {
-  flex-direction: row;
-}
-
-.chat-bubble.guest {
-  flex-direction: row-reverse;
-}
-
-.avatar {
-  width: 40px;
-  height: 40px;
-  border-radius: 50%;
-  background: #3b82f6;
-  color: #fff;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-weight: bold;
-  flex-shrink: 0;
-}
-
-.chat-bubble.guest .avatar {
-  background: #8b5cf6;
-}
-
-.bubble-content {
-  background: #334155;
-  padding: 1rem;
-  border-radius: 12px;
-  border-top-left-radius: 2px;
-  max-width: 80%;
-  line-height: 1.6;
+.toggle-btn:hover {
+  background: rgba(30, 41, 59, 0.8);
+  color: #f1f5f9;
 }
 
-.chat-bubble.guest .bubble-content {
-  background: #475569;
+.report-panel {
+  margin-top: 1rem;
+  background: rgba(15, 23, 42, 0.8);
+  border: 1px solid rgba(255, 255, 255, 0.1);
   border-radius: 12px;
-  border-top-right-radius: 2px;
-}
-
-.speaker-name {
-  font-size: 0.75rem;
-  color: #94a3b8;
-  margin-bottom: 0.25rem;
-  text-transform: uppercase;
-  font-weight: 600;
+  padding: 1.5rem;
+  max-height: 500px;
+  overflow-y: auto;
 }
 
 .markdown-report {
@@ -1528,101 +1691,58 @@ select {
   line-height: 1.7;
 }
 
-.task-summary {
-  font-size: 0.85rem;
-  color: #cbd5e1;
-  margin-top: 0.5rem;
-  padding-top: 0.5rem;
-  border-top: 1px dashed rgba(255, 255, 255, 0.1);
-  line-height: 1.6;
-}
-
-.task-summary :deep(h1),
-.task-summary :deep(h2),
-.task-summary :deep(h3),
-.task-summary :deep(h4) {
-  font-size: 0.95rem;
-  font-weight: 700;
-  margin-top: 0.8rem;
-  margin-bottom: 0.4rem;
-  color: #e2e8f0;
-}
-
-.task-summary :deep(p) {
-  margin-bottom: 0.6rem;
-}
-
-.task-summary :deep(ul),
-.task-summary :deep(ol) {
-  padding-left: 1.2rem;
-  margin-bottom: 0.6rem;
-}
-
-.task-summary :deep(li) {
-  margin-bottom: 0.3rem;
-}
-
-.task-summary :deep(strong) {
-  color: #60a5fa;
-  font-weight: 600;
-}
-
-.task-summary :deep(code) {
-  background: rgba(0, 0, 0, 0.3);
-  padding: 2px 4px;
-  border-radius: 3px;
-  font-family: 'Fira Code', monospace;
-  font-size: 0.8em;
-  color: #f472b6;
-}
-
-.report-content {
-  line-height: 1.8;
-}
-
-.report-content :deep(h1) {
-  font-size: 1.8rem;
-  margin-bottom: 1.5rem;
+.markdown-report :deep(h1) {
+  font-size: 1.6rem;
+  margin-bottom: 1rem;
   color: #60a5fa;
   border-bottom: 1px solid rgba(255, 255, 255, 0.1);
   padding-bottom: 0.5rem;
 }
 
-.report-content :deep(h2) {
-  font-size: 1.4rem;
-  margin-top: 2rem;
-  margin-bottom: 1rem;
+.markdown-report :deep(h2) {
+  font-size: 1.3rem;
+  margin-top: 1.5rem;
+  margin-bottom: 0.8rem;
   color: #c084fc;
 }
 
-.report-content :deep(h3) {
+.markdown-report :deep(h3) {
   font-size: 1.1rem;
-  margin-top: 1.5rem;
-  margin-bottom: 0.8rem;
+  margin-top: 1.2rem;
+  margin-bottom: 0.6rem;
   color: #e2e8f0;
 }
 
-.report-content :deep(p) {
-  margin-bottom: 1rem;
+.markdown-report :deep(p) {
+  margin-bottom: 0.8rem;
   color: #cbd5e1;
 }
 
-.report-content :deep(ul),
-.report-content :deep(ol) {
+.markdown-report :deep(ul),
+.markdown-report :deep(ol) {
   padding-left: 1.5rem;
-  margin-bottom: 1rem;
+  margin-bottom: 0.8rem;
 }
 
-.report-content :deep(li) {
-  margin-bottom: 0.5rem;
+.markdown-report :deep(li) {
+  margin-bottom: 0.4rem;
 }
 
-.report-content :deep(strong) {
+.markdown-report :deep(strong) {
   color: #fff;
   font-weight: 600;
 }
 
-.report-content :deep(blockquote) {
+.markdown-report :deep(code) {
+  background: rgba(0, 0, 0, 0.3);
+  padding: 2px 4px;
+  border-radius: 3px;
+  font-family: 'Fira Code', monospace;
+  font-size: 0.85em;
+  color: #f472b6;
+}
+
+.markdown-report :deep(blockquote) {
   border-left: 4px solid #60a5fa;
   padding-left: 1rem;
   margin: 1rem 0;
@@ -1633,6 +1753,15 @@ select {
   border-radius: 0 4px 4px 0;
 }
 
+.markdown-report :deep(a) {
+  color: #60a5fa;
+  text-decoration: none;
+}
+
+.markdown-report :deep(a:hover) {
+  text-decoration: underline;
+}
+
 /* Transitions */
 .fade-enter-active, .fade-leave-active {
   transition: opacity 0.3s ease;