1
0
Эх сурвалжийг харах

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

JJSun 4 сар өмнө
parent
commit
7bd7e4f6d9
19 өөрчлөгдсөн 985 нэмэгдсэн , 675 устгасан
  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)
 - **搜索增强**: Tavily API, SerpApi (Google Hybrid Search)
 - **音频处理**: Pydub, FFmpeg
 - **音频处理**: 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
 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
     "E",    # pycodestyle
     "F",    # pyflakes
     "F",    # pyflakes
     "I",    # isort
     "I",    # isort
+    "UP",   # pyupgrade
     "D",    # pydocstyle
     "D",    # pydocstyle
-    "D401", # First line should be in imperative mood
-    "T201",
-    "UP",
+    "T20",  # flake8-print
 ]
 ]
 lint.ignore = [
 lint.ignore = [
-    "UP006",
-    "UP007",
-    "UP035",
-    "D417",
-    "E501",
+    "D400",  # 中文 docstring 句号检测误报
+    "D415",  # 中文 docstring 句号检测误报
+    "D212",  # 多行 docstring 首行格式
+    "E501",  # 行过长
 ]
 ]
 
 
 [tool.ruff.lint.per-file-ignores]
 [tool.ruff.lint.per-file-ignores]
 "tests/*" = ["D", "UP"]
 "tests/*" = ["D", "UP"]
+"scripts/*" = ["T201"]
 
 
 [tool.ruff.lint.pydocstyle]
 [tool.ruff.lint.pydocstyle]
 convention = "google"
 convention = "google"

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

@@ -4,28 +4,29 @@ from __future__ import annotations
 
 
 import logging
 import logging
 import re
 import re
+from collections.abc import Callable, Iterator
 from pathlib import Path
 from pathlib import Path
 from queue import Empty, Queue
 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 import HelloAgentsLLM, ToolAwareSimpleAgent
 from hello_agents.tools import ToolRegistry
 from hello_agents.tools import ToolRegistry
 from hello_agents.tools.builtin.note_tool import NoteTool
 from hello_agents.tools.builtin.note_tool import NoteTool
 
 
 from config import Configuration
 from config import Configuration
+from models import SummaryState, SummaryStateOutput, TodoItem
 from prompts import (
 from prompts import (
     report_writer_instructions,
     report_writer_instructions,
     script_writer_instructions,
     script_writer_instructions,
     task_summarizer_instructions,
     task_summarizer_instructions,
     todo_planner_system_prompt,
     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.planner import PlanningService
 from services.reporter import ReportingService
 from services.reporter import ReportingService
 from services.script_generator import ScriptGenerationService
 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.search import dispatch_search, prepare_research_context
 from services.summarizer import SummarizationService
 from services.summarizer import SummarizationService
 from services.tool_events import ToolCallTracker
 from services.tool_events import ToolCallTracker
@@ -33,6 +34,11 @@ from services.tool_events import ToolCallTracker
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
+class CancelledException(Exception):
+    """研究任务被用户取消时抛出的异常。"""
+    pass
+
+
 class DeepResearchAgent:
 class DeepResearchAgent:
     """使用 HelloAgents 协调基于 TODO 的研究工作流的协调器。"""
     """使用 HelloAgents 协调基于 TODO 的研究工作流的协调器。"""
 
 
@@ -59,6 +65,7 @@ class DeepResearchAgent:
         )
         )
         self._tool_event_sink_enabled = False
         self._tool_event_sink_enabled = False
         self._state_lock = Lock()
         self._state_lock = Lock()
+        self._cancel_event = Event()  # 取消信号
 
 
         self.todo_agent = self._create_tool_aware_agent(
         self.todo_agent = self._create_tool_aware_agent(
             name="研究规划专家",
             name="研究规划专家",
@@ -91,6 +98,20 @@ class DeepResearchAgent:
         self.podcast_synthesizer = PodcastSynthesisService(self.config)
         self.podcast_synthesizer = PodcastSynthesisService(self.config)
         self._last_search_notices: list[str] = []
         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
     # 公共 API
     # ------------------------------------------------------------------
     # ------------------------------------------------------------------
@@ -167,7 +188,7 @@ class DeepResearchAgent:
         audio_files = self.audio_generator.generate_audio(script, task_id)
         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(
         return SummaryStateOutput(
             running_summary=report,
             running_summary=report,
@@ -187,11 +208,21 @@ class DeepResearchAgent:
         3. 实时流式传输任务状态、搜索结果和部分总结。
         3. 实时流式传输任务状态、搜索结果和部分总结。
         4. 所有任务完成后,生成并流式传输最终报告。
         4. 所有任务完成后,生成并流式传输最终报告。
         5. 生成并流式传输播客脚本和音频合成进度。
         5. 生成并流式传输播客脚本和音频合成进度。
+        
+        支持通过 cancel() 方法取消执行。
         """
         """
+        # 重置取消状态
+        self._cancel_event.clear()
+        
         state = SummaryState(research_topic=topic)
         state = SummaryState(research_topic=topic)
         logger.debug("Starting streaming research: topic=%s", topic)
         logger.debug("Starting streaming research: topic=%s", topic)
         yield {"type": "status", "message": "初始化研究流程"}
         yield {"type": "status", "message": "初始化研究流程"}
 
 
+        # 检查取消
+        if self.is_cancelled():
+            yield {"type": "cancelled", "message": "研究任务已取消"}
+            return
+
         state.todo_items = self.planner.plan_todo_list(state)
         state.todo_items = self.planner.plan_todo_list(state)
         for event in self._drain_tool_events(state, step=0):
         for event in self._drain_tool_events(state, step=0):
             yield event
             yield event
@@ -245,6 +276,11 @@ class DeepResearchAgent:
 
 
         def worker(task: TodoItem, step: int) -> None:
         def worker(task: TodoItem, step: int) -> None:
             try:
             try:
+                # 检查取消状态
+                if self.is_cancelled():
+                    enqueue({"type": "__task_done__", "task_id": task.id})
+                    return
+                    
                 enqueue(
                 enqueue(
                     {
                     {
                         "type": "task_status",
                         "type": "task_status",
@@ -260,7 +296,12 @@ class DeepResearchAgent:
                 )
                 )
 
 
                 for event in self._execute_task(state, task, emit_stream=True, step=step):
                 for event in self._execute_task(state, task, emit_stream=True, step=step):
+                    # 在每个事件之后检查取消
+                    if self.is_cancelled():
+                        break
                     enqueue(event, task=task)
                     enqueue(event, task=task)
+            except CancelledException:
+                logger.info("Task %s cancelled", task.id)
             except Exception as exc:  # pragma: no cover - defensive guardrail
             except Exception as exc:  # pragma: no cover - defensive guardrail
                 logger.exception("Task execution failed", exc_info=exc)
                 logger.exception("Task execution failed", exc_info=exc)
                 enqueue(
                 enqueue(
@@ -291,7 +332,17 @@ class DeepResearchAgent:
 
 
         try:
         try:
             while finished_workers < active_workers:
             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__":
                 if event.get("type") == "__task_done__":
                     finished_workers += 1
                     finished_workers += 1
                     continue
                     continue
@@ -307,7 +358,12 @@ class DeepResearchAgent:
         finally:
         finally:
             self._set_tool_event_sink(None)
             self._set_tool_event_sink(None)
             for thread in threads:
             for thread in threads:
-                thread.join()
+                thread.join(timeout=1.0)
+
+        # 检查取消
+        if self.is_cancelled():
+            yield {"type": "cancelled", "message": "研究任务已取消"}
+            return
 
 
         yield {
         yield {
             "type": "stage_change",
             "type": "stage_change",
@@ -323,6 +379,11 @@ class DeepResearchAgent:
         state.running_summary = report
         state.running_summary = report
         yield {"type": "log", "message": f"✓ 报告撰写完成,共 {len(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)
         note_event = self._persist_final_report(state, report)
         if note_event:
         if note_event:
             yield note_event
             yield note_event
@@ -334,6 +395,11 @@ class DeepResearchAgent:
             "note_path": state.report_note_path,
             "note_path": state.report_note_path,
         }
         }
 
 
+        # 检查取消
+        if self.is_cancelled():
+            yield {"type": "cancelled", "message": "研究任务已取消"}
+            return
+
         yield {
         yield {
             "type": "stage_change",
             "type": "stage_change",
             "stage": "script",
             "stage": "script",
@@ -358,6 +424,11 @@ class DeepResearchAgent:
             "turns": script_turns,
             "turns": script_turns,
         }
         }
 
 
+        # 检查取消
+        if self.is_cancelled():
+            yield {"type": "cancelled", "message": "研究任务已取消"}
+            return
+
         yield {
         yield {
             "type": "stage_change",
             "type": "stage_change",
             "stage": "audio",
             "stage": "audio",
@@ -369,9 +440,13 @@ class DeepResearchAgent:
         audio_event_queue: Queue[dict[str, Any]] = Queue()
         audio_event_queue: Queue[dict[str, Any]] = Queue()
         audio_result: list = []
         audio_result: list = []
         audio_error: list = []
         audio_error: list = []
+        cancel_audio = Event()  # 用于取消音频生成的信号
         
         
         def audio_progress_callback(current, total, role, preview):
         def audio_progress_callback(current, total, role, preview):
             """将进度事件放入队列以实现实时更新"""
             """将进度事件放入队列以实现实时更新"""
+            # 检查是否应该取消
+            if self.is_cancelled() or cancel_audio.is_set():
+                return False  # 返回 False 表示应该停止
             audio_event_queue.put({
             audio_event_queue.put({
                 "type": "audio_progress",
                 "type": "audio_progress",
                 "current": current,
                 "current": current,
@@ -380,6 +455,7 @@ class DeepResearchAgent:
                 "preview": preview,
                 "preview": preview,
                 "message": f"[TTS {current}/{total}] 正在为 {role} 生成语音: {preview}",
                 "message": f"[TTS {current}/{total}] 正在为 {role} 生成语音: {preview}",
             })
             })
+            return True  # 返回 True 表示继续
         
         
         def run_audio_generation():
         def run_audio_generation():
             """在单独线程中运行音频生成"""
             """在单独线程中运行音频生成"""
@@ -387,7 +463,8 @@ class DeepResearchAgent:
                 files = self.audio_generator.generate_audio(script, task_id, audio_progress_callback)
                 files = self.audio_generator.generate_audio(script, task_id, audio_progress_callback)
                 audio_result.append(files)
                 audio_result.append(files)
             except Exception as e:
             except Exception as e:
-                audio_error.append(str(e))
+                if not self.is_cancelled():
+                    audio_error.append(str(e))
             finally:
             finally:
                 audio_event_queue.put({"type": "_audio_done"})
                 audio_event_queue.put({"type": "_audio_done"})
         
         
@@ -405,6 +482,13 @@ class DeepResearchAgent:
         
         
         # 实时流式传输进度事件
         # 实时流式传输进度事件
         while True:
         while True:
+            # 检查取消
+            if self.is_cancelled():
+                cancel_audio.set()  # 通知音频生成线程停止
+                yield {"type": "cancelled", "message": "研究任务已取消"}
+                audio_thread.join(timeout=2.0)
+                return
+                
             try:
             try:
                 event = audio_event_queue.get(timeout=0.1)
                 event = audio_event_queue.get(timeout=0.1)
                 if event.get("type") == "_audio_done":
                 if event.get("type") == "_audio_done":
@@ -421,6 +505,11 @@ class DeepResearchAgent:
         
         
         audio_thread.join(timeout=5.0)
         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_files = audio_result[0] if audio_result else []
         audio_count = len(audio_files) if audio_files else 0
         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
 BACKEND_ROOT = Path(__file__).resolve().parent.parent
 
 
 class SearchAPI(Enum):
 class SearchAPI(Enum):
+    """可用的搜索 API 提供商。"""
     HYBRID = "hybrid"
     HYBRID = "hybrid"
     TAVILY = "tavily"
     TAVILY = "tavily"
     SERPAPI = "serpapi"
     SERPAPI = "serpapi"
@@ -78,7 +79,7 @@ class Configuration(BaseModel):
         description="Model ID for complex reasoning tasks (e.g. Planning, Reporting)",
         description="Model ID for complex reasoning tasks (e.g. Planning, Reporting)",
     )
     )
     fast_llm_model: Optional[str] = Field(
     fast_llm_model: Optional[str] = Field(
-        default="ecnu-plus",
+        default="ecnu-max",
         title="Fast LLM Model",
         title="Fast LLM Model",
         description="Model ID for simple/fast tasks (e.g. Summarization)",
         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
 from __future__ import annotations
 
 
+import asyncio
 import json
 import json
 import os
 import os
 import sys
 import sys
-from typing import Any, Dict, Iterator, Optional
+from typing import Any
 
 
 # Ensure src directory is in sys.path for module imports
 # Ensure src directory is in sys.path for module imports
 sys.path.append(os.path.dirname(os.path.abspath(__file__)))
 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.middleware.cors import CORSMiddleware
 from fastapi.responses import StreamingResponse
 from fastapi.responses import StreamingResponse
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
 from loguru import logger
 from loguru import logger
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
 
 
-from config import Configuration, SearchAPI
 from agent import DeepResearchAgent
 from agent import DeepResearchAgent
+from config import Configuration, SearchAPI
 
 
 # 添加控制台日志处理程序
 # 添加控制台日志处理程序
 logger.add(
 logger.add(
@@ -62,13 +63,13 @@ class ResearchResponse(BaseModel):
         default_factory=list,
         default_factory=list,
         description="带有摘要和来源的结构化待办事项",
         description="带有摘要和来源的结构化待办事项",
     )
     )
-    podcast_script: Optional[PodcastScript] = Field(
+    podcast_script: PodcastScript | None = Field(
         default=None,
         default=None,
         description="生成的播客脚本内容",
         description="生成的播客脚本内容",
     )
     )
 
 
 
 
-def _mask_secret(value: Optional[str], visible: int = 4) -> str:
+def _mask_secret(value: str | None, visible: int = 4) -> str:
     """在保持前导和尾随字符的同时,掩盖敏感令牌。"""
     """在保持前导和尾随字符的同时,掩盖敏感令牌。"""
     if not value:
     if not value:
         return "unset"
         return "unset"
@@ -80,7 +81,7 @@ def _mask_secret(value: Optional[str], visible: int = 4) -> str:
 
 
 
 
 def _build_config(payload: ResearchRequest) -> Configuration:
 def _build_config(payload: ResearchRequest) -> Configuration:
-    overrides: Dict[str, Any] = {}
+    overrides: dict[str, Any] = {}
 
 
     if payload.search_api is not None:
     if payload.search_api is not None:
         overrides["search_api"] = payload.search_api
         overrides["search_api"] = payload.search_api
@@ -89,6 +90,7 @@ def _build_config(payload: ResearchRequest) -> Configuration:
 
 
 
 
 def create_app() -> FastAPI:
 def create_app() -> FastAPI:
+    """创建并配置 FastAPI 应用实例。"""
     app = FastAPI(title="DeepCast - 自动播客生成智能体")
     app = FastAPI(title="DeepCast - 自动播客生成智能体")
 
 
     app.add_middleware(
     app.add_middleware(
@@ -113,19 +115,12 @@ def create_app() -> FastAPI:
         """记录启动时的关键配置参数。"""
         """记录启动时的关键配置参数。"""
         config = Configuration.from_env()
         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(
         logger.info(
             "DeepResearch configuration loaded: provider=%s model=%s base_url=%s search_api=%s "
             "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",
             "max_loops=%s fetch_full_page=%s tool_calling=%s strip_thinking=%s api_key=%s",
             config.llm_provider,
             config.llm_provider,
             config.resolved_model() or "unset",
             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.search_api.value if isinstance(config.search_api, SearchAPI) else config.search_api),
             config.max_web_research_loops,
             config.max_web_research_loops,
             config.fetch_full_page,
             config.fetch_full_page,
@@ -135,7 +130,7 @@ def create_app() -> FastAPI:
         )
         )
 
 
     @app.get("/healthz")
     @app.get("/healthz")
-    def health_check() -> Dict[str, str]:
+    def health_check() -> dict[str, str]:
         return {"status": "ok"}
         return {"status": "ok"}
 
 
     @app.post("/research", response_model=ResearchResponse)
     @app.post("/research", response_model=ResearchResponse)
@@ -169,21 +164,29 @@ def create_app() -> FastAPI:
             for item in result.todo_items
             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(
         return ResearchResponse(
             report_markdown=(result.report_markdown or result.running_summary or ""),
             report_markdown=(result.report_markdown or result.running_summary or ""),
             todo_items=todo_payload,
             todo_items=todo_payload,
-            podcast_script=podcast_script,
+            podcast_script=podcast_resp,
         )
         )
 
 
     @app.post("/research/stream")
     @app.post("/research/stream")
-    def stream_research(payload: ResearchRequest) -> StreamingResponse:
+    async def stream_research(payload: ResearchRequest, request: Request) -> StreamingResponse:
         """
         """
         触发流式研究任务。
         触发流式研究任务。
         
         
         通过 Server-Sent Events (SSE) 实时返回研究进度、日志和部分结果。
         通过 Server-Sent Events (SSE) 实时返回研究进度、日志和部分结果。
+        支持客户端断开连接时自动取消后端任务。
         """
         """
         try:
         try:
             config = _build_config(payload)
             config = _build_config(payload)
@@ -191,10 +194,41 @@ def create_app() -> FastAPI:
         except ValueError as exc:
         except ValueError as exc:
             raise HTTPException(status_code=400, detail=str(exc)) from exc
             raise HTTPException(status_code=400, detail=str(exc)) from exc
 
 
-        def event_iterator() -> Iterator[str]:
+        async def event_iterator():
             try:
             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
             except Exception as exc:  # pragma: no cover - defensive guardrail
                 logger.exception("Streaming research failed")
                 logger.exception("Streaming research failed")
                 error_payload = {"type": "error", "detail": str(exc)}
                 error_payload = {"type": "error", "detail": str(exc)}

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

@@ -2,9 +2,7 @@
 
 
 import operator
 import operator
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
-from typing import List, Optional
-
-from typing_extensions import Annotated
+from typing import Annotated
 
 
 
 
 @dataclass(kw_only=True)
 @dataclass(kw_only=True)
@@ -16,38 +14,53 @@ class TodoItem:
     intent: str
     intent: str
     query: str
     query: str
     status: str = field(default="pending")
     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)
     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)
 @dataclass(kw_only=True)
 class SummaryState:
 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)
     web_research_results: Annotated[list, operator.add] = field(default_factory=list)
     sources_gathered: 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)  # 研究循环次数
     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)  # 待办任务项列表
     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)
 @dataclass(kw_only=True)
 class SummaryStateInput:
 class SummaryStateInput:
-    research_topic: str = field(default=None)  # 研究主题
+    """深度研究工作流的输入状态模型。
+    
+    用于指定研究主题。
+    """
+
+    research_topic: str | None = field(default=None)  # 研究主题
 
 
 
 
 @dataclass(kw_only=True)
 @dataclass(kw_only=True)
 class SummaryStateOutput:
 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
 from datetime import datetime
 
 
 
 
-# 以可读格式获取当前日期
 def get_current_date():
 def get_current_date():
-    return datetime.now().strftime("%B %d, %Y")
-
+    """以中文格式获取当前日期"""
+    return datetime.now().strftime("%Y年%m月%d日")
 
 
 
 
+# ============================================================
+# 研究规划专家 - 任务拆解
+# ============================================================
 todo_planner_system_prompt = """
 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 = """
 todo_planner_instructions = """
-
-<CONTEXT>
+<研究上下文>
 当前日期:{current_date}
 当前日期:{current_date}
 研究主题:{research_topic}
 研究主题:{research_topic}
-</CONTEXT>
+</研究上下文>
 
 
-<FORMAT>
-请严格以 JSON 格式回复
+<输出格式>
+请以 JSON 格式输出任务列表
 {{
 {{
   "tasks": [
   "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 = """
 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_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
 from __future__ import annotations
 
 
 import logging
 import logging
-import os
-import requests
+from collections.abc import Callable
 from pathlib import Path
 from pathlib import Path
-from typing import List, Optional, Callable
+
+import requests
 
 
 from config import Configuration
 from config import Configuration
-from pydub import AudioSegment
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -18,6 +17,12 @@ class AudioGenerationService:
     """处理与 TTS 服务的交互以生成音频文件。"""
     """处理与 TTS 服务的交互以生成音频文件。"""
 
 
     def __init__(self, config: Configuration) -> None:
     def __init__(self, config: Configuration) -> None:
+        """
+        初始化音频生成服务。
+
+        Args:
+            config: 包含 TTS 配置和输出路径的配置对象。
+        """
         self._config = config
         self._config = config
         self._output_dir = Path(config.audio_output_dir)
         self._output_dir = Path(config.audio_output_dir)
         self._ensure_output_dir()
         self._ensure_output_dir()
@@ -37,17 +42,18 @@ class AudioGenerationService:
 
 
     def generate_audio(
     def generate_audio(
         self, 
         self, 
-        script: List[dict[str, str]], 
+        script: list[dict[str, str]], 
         task_id: str = "default",
         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:
         Args:
             script: 对话回合列表,例如 [{"role": "Host", "content": "..."}, ...]
             script: 对话回合列表,例如 [{"role": "Host", "content": "..."}, ...]
             task_id: 当前任务/会话的唯一标识符
             task_id: 当前任务/会话的唯一标识符
-            progress_callback: 可选的进度回调函数,签名为 (current, total, role, content_preview) -> None
+            progress_callback: 可选的进度回调函数,签名为 (current, total, role, content_preview) -> Optional[bool]
+                              返回 False 表示应该停止生成,返回 True 或 None 表示继续
             
             
         Returns:
         Returns:
             生成的音频文件的路径列表
             生成的音频文件的路径列表
@@ -70,10 +76,13 @@ class AudioGenerationService:
             if not role or not content:
             if not role or not content:
                 continue
                 continue
             
             
-            # 调用进度回调
+            # 调用进度回调,检查是否应该停止
             if progress_callback:
             if progress_callback:
                 content_preview = content[:30] + "..." if len(content) > 30 else content
                 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)
             voice_id = self._get_voice_for_role(role)
             if not voice_id:
             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
 import logging
 from pathlib import Path
 from pathlib import Path
-from typing import List
 
 
 from pydub import AudioSegment
 from pydub import AudioSegment
 
 
@@ -17,6 +16,12 @@ class PodcastSynthesisService:
     """将多个音频片段组合成最终的播客文件。"""
     """将多个音频片段组合成最终的播客文件。"""
 
 
     def __init__(self, config: Configuration) -> None:
     def __init__(self, config: Configuration) -> None:
+        """
+        初始化音频合成服务。
+
+        Args:
+            config: 包含 ffmpeg 路径和输出路径的配置对象。
+        """
         self._config = config
         self._config = config
         self._output_dir = Path(config.audio_output_dir)
         self._output_dir = Path(config.audio_output_dir)
         
         
@@ -28,7 +33,7 @@ class PodcastSynthesisService:
         # 确保 pydub/ffmpeg 可用 - 假设 ffmpeg 已安装在系统中
         # 确保 pydub/ffmpeg 可用 - 假设 ffmpeg 已安装在系统中
         # 如果没有,pydub 可能会发出警告或失败,但我们会捕获异常。
         # 如果没有,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。
         将音频文件组合成单个播客 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:
 def build_note_guidance(task: TodoItem) -> str:
     """为特定任务生成笔记工具使用说明。"""
     """为特定任务生成笔记工具使用说明。"""
-
     tags_list = ["deep_research", f"task_{task.id}"]
     tags_list = ["deep_research", f"task_{task.id}"]
     tags_literal = json.dumps(tags_list, ensure_ascii=False)
     tags_literal = json.dumps(tags_list, ensure_ascii=False)
 
 
     if task.note_id:
     if task.note_id:
         read_payload = json.dumps({"action": "read", "note_id": task.note_id}, ensure_ascii=False)
         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",
                 "action": "update",
                 "note_id": task.note_id,
                 "note_id": task.note_id,
@@ -23,7 +23,7 @@ def build_note_guidance(task: TodoItem) -> str:
                 "title": f"任务 {task.id}: {task.title}",
                 "title": f"任务 {task.id}: {task.title}",
                 "note_type": "task_state",
                 "note_type": "task_state",
                 "tags": tags_list,
                 "tags": tags_list,
-                "content": "请将本轮新增信息补充到任务概览中",
+                "content": "<请在此填写更新后的完整内容>",
             },
             },
             ensure_ascii=False,
             ensure_ascii=False,
         )
         )
@@ -32,27 +32,33 @@ def build_note_guidance(task: TodoItem) -> str:
             "笔记协作指引:\n"
             "笔记协作指引:\n"
             f"- 当前任务笔记 ID:{task.note_id}。\n"
             f"- 当前任务笔记 ID:{task.note_id}。\n"
             f"- 在书写总结前必须调用:[TOOL_CALL:note:{read_payload}] 获取最新内容。\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"
             "- 更新时保持原有段落结构,新增内容请在对应段落中补充。\n"
             f"- 建议 tags 保持为 {tags_literal},保证其他 Agent 可快速定位。\n"
             f"- 建议 tags 保持为 {tags_literal},保证其他 Agent 可快速定位。\n"
             "- 成功同步到笔记后,再输出面向用户的总结。\n"
             "- 成功同步到笔记后,再输出面向用户的总结。\n"
         )
         )
 
 
-    create_payload = json.dumps(
+    # 只提供创建笔记的模板,让 LLM 自行填充实际研究内容
+    create_template = json.dumps(
         {
         {
             "action": "create",
             "action": "create",
             "task_id": task.id,
             "task_id": task.id,
             "title": f"任务 {task.id}: {task.title}",
             "title": f"任务 {task.id}: {task.title}",
             "note_type": "task_state",
             "note_type": "task_state",
             "tags": tags_list,
             "tags": tags_list,
-            "content": "请记录任务概览、来源概览",
+            "content": "<请在此填写任务总结内容>",
         },
         },
         ensure_ascii=False,
         ensure_ascii=False,
     )
     )
 
 
     return (
     return (
         "笔记协作指引:\n"
         "笔记协作指引:\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"
         "- 创建成功后记录返回的 note_id,并在后续所有更新中复用。\n"
         "- 同步笔记后,再输出面向用户的总结。\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 json
 import logging
 import logging
 import re
 import re
-from typing import Any, List, Optional
+from typing import Any
 
 
 from hello_agents import ToolAwareSimpleAgent
 from hello_agents import ToolAwareSimpleAgent
 
 
-from models import SummaryState, TodoItem
 from config import Configuration
 from config import Configuration
+from models import SummaryState, TodoItem
 from prompts import get_current_date, todo_planner_instructions
 from prompts import get_current_date, todo_planner_instructions
 from utils import strip_thinking_tokens
 from utils import strip_thinking_tokens
 
 
@@ -28,7 +28,7 @@ class PlanningService:
         self._agent = planner_agent
         self._agent = planner_agent
         self._config = config
         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:
         Returns:
             规划出的 TodoItem 列表。
             规划出的 TodoItem 列表。
         """
         """
-
         prompt = todo_planner_instructions.format(
         prompt = todo_planner_instructions.format(
             current_date=get_current_date(),
             current_date=get_current_date(),
             research_topic=state.research_topic,
             research_topic=state.research_topic,
@@ -50,7 +49,7 @@ class PlanningService:
         logger.info("Planner raw output (truncated): %s", response[:500])
         logger.info("Planner raw output (truncated): %s", response[:500])
 
 
         tasks_payload = self._extract_tasks(response)
         tasks_payload = self._extract_tasks(response)
-        todo_items: List[TodoItem] = []
+        todo_items: list[TodoItem] = []
 
 
         for idx, item in enumerate(tasks_payload, start=1):
         for idx, item in enumerate(tasks_payload, start=1):
             title = str(item.get("title") or f"任务{idx}").strip()
             title = str(item.get("title") or f"任务{idx}").strip()
@@ -81,7 +80,6 @@ class PlanningService:
         
         
         当 LLM 无法生成有效的 JSON 任务列表时调用。
         当 LLM 无法生成有效的 JSON 任务列表时调用。
         """
         """
-
         return TodoItem(
         return TodoItem(
             id=1,
             id=1,
             title="基础背景梳理",
             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。
         支持纯 JSON 格式或嵌入在工具调用中的 JSON。
         """
         """
-
         text = raw_response.strip()
         text = raw_response.strip()
         if self._config.strip_thinking_tokens:
         if self._config.strip_thinking_tokens:
             text = strip_thinking_tokens(text)
             text = strip_thinking_tokens(text)
 
 
         json_payload = self._extract_json_payload(text)
         json_payload = self._extract_json_payload(text)
-        tasks: List[dict[str, Any]] = []
+        tasks: list[dict[str, Any]] = []
 
 
         if isinstance(json_payload, dict):
         if isinstance(json_payload, dict):
             candidate = json_payload.get("tasks")
             candidate = json_payload.get("tasks")
@@ -126,9 +123,8 @@ class PlanningService:
 
 
         return tasks
         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 对象或数组。"""
         """尝试从文本中定位并解析 JSON 对象或数组。"""
-
         start = text.find("{")
         start = text.find("{")
         end = text.rfind("}")
         end = text.rfind("}")
         if start != -1 and end != -1 and end > start:
         if start != -1 and end != -1 and end > start:
@@ -149,9 +145,8 @@ class PlanningService:
 
 
         return None
         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 表达式。"""
         """解析输出中的第一个 TOOL_CALL 表达式。"""
-
         match = TOOL_CALL_PATTERN.search(text)
         match = TOOL_CALL_PATTERN.search(text)
         if not match:
         if not match:
             return None
             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 hello_agents import ToolAwareSimpleAgent
 
 
-from models import SummaryState
 from config import Configuration
 from config import Configuration
-from utils import strip_thinking_tokens
+from models import SummaryState
 from services.text_processing import strip_tool_calls
 from services.text_processing import strip_tool_calls
+from utils import strip_thinking_tokens
 
 
 
 
 class ReportingService:
 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._agent = report_agent
         self._config = config
         self._config = config
 
 
     def generate_report(self, state: SummaryState) -> str:
     def generate_report(self, state: SummaryState) -> str:
         """
         """
         基于完成的任务生成结构化报告。
         基于完成的任务生成结构化报告。
-        
+
         Args:
         Args:
             state: 包含任务结果和笔记的研究状态。
             state: 包含任务结果和笔记的研究状态。
-            
+
         Returns:
         Returns:
             Markdown 格式的报告文本。
             Markdown 格式的报告文本。
         """
         """
-
         tasks_block = []
         tasks_block = []
         for task in state.todo_items:
         for task in state.todo_items:
             summary_block = task.summary or "暂无可用信息"
             summary_block = task.summary or "暂无可用信息"
@@ -50,16 +51,21 @@ class ReportingService:
                     f"- 任务 {task.id}《{task.title}》:note_id={task.note_id}"
                     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(
         create_conclusion_template = json.dumps(
             {
             {
                 "action": "create",
                 "action": "create",
                 "title": f"研究报告:{state.research_topic}",
                 "title": f"研究报告:{state.research_topic}",
                 "note_type": "conclusion",
                 "note_type": "conclusion",
                 "tags": ["deep_research", "report"],
                 "tags": ["deep_research", "report"],
-                "content": "请在此沉淀最终报告要点",
+                "content": "<请在此填写报告核心要点>",
             },
             },
             ensure_ascii=False,
             ensure_ascii=False,
         )
         )
@@ -69,7 +75,9 @@ class ReportingService:
             f"任务概览:\n{''.join(tasks_block)}\n"
             f"任务概览:\n{''.join(tasks_block)}\n"
             f"可用任务笔记:\n{notes_section}\n"
             f"可用任务笔记:\n{notes_section}\n"
             f"请针对每条任务笔记使用格式:[TOOL_CALL:note:{read_template}] 读取内容,整合所有信息后撰写报告。\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)
         response = self._agent.run(prompt)
@@ -82,4 +90,3 @@ class ReportingService:
         report_text = strip_tool_calls(report_text).strip()
         report_text = strip_tool_calls(report_text).strip()
 
 
         return report_text or "报告生成失败,请检查输入。"
         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 json
 import logging
 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 config import Configuration
-from utils import strip_thinking_tokens
+from models import SummaryState
+from prompts import script_writer_instructions
 
 
 logger = logging.getLogger(__name__)
 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:
 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
         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:
         if not state.structured_report:
             logger.warning("No structured report available for script generation.")
             logger.warning("No structured report available for script generation.")
             return []
             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:
         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):
             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 []
                 return []
             
             
-            # 验证脚本格式
+            # 验证并标准化
             valid_script = []
             valid_script = []
             for item in script:
             for item in script:
                 if isinstance(item, dict) and "role" in item and "content" in item:
                 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))
             logger.info("Generated script with %d dialogue turns.", len(valid_script))
             return valid_script
             return valid_script
 
 
         except json.JSONDecodeError as e:
         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
 from __future__ import annotations
 
 
 import logging
 import logging
-from typing import Any, Optional, Tuple
+from typing import Any
 
 
 from hello_agents.tools import SearchTool
 from hello_agents.tools import SearchTool
 
 
@@ -36,7 +36,7 @@ def dispatch_search(
     query: str,
     query: str,
     config: Configuration,
     config: Configuration,
     loop_count: int,
     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(
 def prepare_research_context(
     search_result: dict[str, Any] | None,
     search_result: dict[str, Any] | None,
-    answer_text: Optional[str],
+    answer_text: str | None,
     config: Configuration,
     config: Configuration,
 ) -> tuple[str, str]:
 ) -> tuple[str, str]:
     """
     """

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

@@ -3,21 +3,20 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 from collections.abc import Callable, Iterator
 from collections.abc import Callable, Iterator
-from typing import Tuple
 
 
 from hello_agents import ToolAwareSimpleAgent
 from hello_agents import ToolAwareSimpleAgent
 
 
-from models import SummaryState, TodoItem
 from config import Configuration
 from config import Configuration
-from utils import strip_thinking_tokens
+from models import SummaryState, TodoItem
 from services.notes import build_note_guidance
 from services.notes import build_note_guidance
 from services.text_processing import strip_tool_calls
 from services.text_processing import strip_tool_calls
+from utils import strip_thinking_tokens
 
 
 
 
 class SummarizationService:
 class SummarizationService:
     """处理同步和流式任务总结。"""
     """处理同步和流式任务总结。"""
 
 
-    def __init__(
+    def __init__(  # noqa: D107
         self,
         self,
         summarizer_factory: Callable[[], ToolAwareSimpleAgent],
         summarizer_factory: Callable[[], ToolAwareSimpleAgent],
         config: Configuration,
         config: Configuration,
@@ -27,7 +26,6 @@ class SummarizationService:
 
 
     def summarize_task(self, state: SummaryState, task: TodoItem, context: str) -> str:
     def summarize_task(self, state: SummaryState, task: TodoItem, context: str) -> str:
         """使用总结代理生成特定于任务的总结。"""
         """使用总结代理生成特定于任务的总结。"""
-
         prompt = self._build_prompt(state, task, context)
         prompt = self._build_prompt(state, task, context)
 
 
         agent = self._agent_factory()
         agent = self._agent_factory()
@@ -46,9 +44,8 @@ class SummarizationService:
 
 
     def stream_task_summary(
     def stream_task_summary(
         self, state: SummaryState, task: TodoItem, context: str
         self, state: SummaryState, task: TodoItem, context: str
-    ) -> Tuple[Iterator[str], Callable[[], str]]:
+    ) -> tuple[Iterator[str], Callable[[], str]]:
         """流式传输任务的总结文本,同时收集完整输出。"""
         """流式传输任务的总结文本,同时收集完整输出。"""
-
         prompt = self._build_prompt(state, task, context)
         prompt = self._build_prompt(state, task, context)
         remove_thinking = self._config.strip_thinking_tokens
         remove_thinking = self._config.strip_thinking_tokens
         raw_buffer = ""
         raw_buffer = ""
@@ -60,7 +57,7 @@ class SummarizationService:
             """
             """
             处理缓冲区,提取并 yield 所有不在 <think>...</think> 块中的可见文本。
             处理缓冲区,提取并 yield 所有不在 <think>...</think> 块中的可见文本。
             如果遇到不完整的 <think> 标签,会暂停输出等待更多数据。
             如果遇到不完整的 <think> 标签,会暂停输出等待更多数据。
-            """
+            """  # noqa: D205
             nonlocal emit_index, raw_buffer
             nonlocal emit_index, raw_buffer
             while True:
             while True:
                 start = raw_buffer.find("<think>", emit_index)
                 start = raw_buffer.find("<think>", emit_index)
@@ -117,7 +114,6 @@ class SummarizationService:
 
 
     def _build_prompt(self, state: SummaryState, task: TodoItem, context: str) -> str:
     def _build_prompt(self, state: SummaryState, task: TodoItem, context: str) -> str:
         """构建两种模式共享的总结提示。"""
         """构建两种模式共享的总结提示。"""
-
         return (
         return (
             f"任务主题:{state.research_topic}\n"
             f"任务主题:{state.research_topic}\n"
             f"任务名称:{task.title}\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:
 def strip_tool_calls(text: str) -> str:
     """移除文本中的工具调用标记。"""
     """移除文本中的工具调用标记。"""
-
     if not text:
     if not text:
         return 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 logging
 import re
 import re
+from collections.abc import Callable
 from dataclasses import dataclass
 from dataclasses import dataclass
 from pathlib import Path
 from pathlib import Path
 from threading import Lock
 from threading import Lock
-from typing import Any, Callable, Optional
+from typing import Any
 
 
 from models import SummaryState, TodoItem
 from models import SummaryState, TodoItem
 
 
@@ -24,19 +25,19 @@ class ToolCallEvent:
     raw_parameters: str
     raw_parameters: str
     parsed_parameters: dict[str, Any]
     parsed_parameters: dict[str, Any]
     result: str
     result: str
-    task_id: Optional[int]
-    note_id: Optional[str]
+    task_id: int | None
+    note_id: str | None
 
 
 
 
 class ToolCallTracker:
 class ToolCallTracker:
     """收集工具调用事件并将其转换为 SSE 负载。"""
     """收集工具调用事件并将其转换为 SSE 负载。"""
 
 
-    def __init__(self, notes_workspace: Optional[str]) -> None:
+    def __init__(self, notes_workspace: str | None) -> None:
         self._notes_workspace = notes_workspace
         self._notes_workspace = notes_workspace
         self._events: list[ToolCallEvent] = []
         self._events: list[ToolCallEvent] = []
         self._cursor = 0
         self._cursor = 0
         self._lock = Lock()
         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:
     def record(self, payload: dict[str, Any]) -> None:
         """
         """
@@ -45,7 +46,6 @@ class ToolCallTracker:
         Args:
         Args:
             payload: 工具调用事件负载,包含工具名、参数和结果。
             payload: 工具调用事件负载,包含工具名、参数和结果。
         """
         """
-
         agent_name = str(payload.get("agent_name") or "unknown")
         agent_name = str(payload.get("agent_name") or "unknown")
         tool_name = str(payload.get("tool_name") or "unknown")
         tool_name = str(payload.get("tool_name") or "unknown")
         raw_parameters = str(payload.get("raw_parameters") or "")
         raw_parameters = str(payload.get("raw_parameters") or "")
@@ -56,7 +56,7 @@ class ToolCallTracker:
             parsed_parameters = {}
             parsed_parameters = {}
 
 
         task_id = self._infer_task_id(parsed_parameters)
         task_id = self._infer_task_id(parsed_parameters)
-        note_id: Optional[str] = None
+        note_id: str | None = None
 
 
         if tool_name == "note":
         if tool_name == "note":
             note_id = parsed_parameters.get("note_id")
             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。
         提取尚未消费的工具调用事件,并同步任务的 note_id。
         
         
@@ -107,7 +107,6 @@ class ToolCallTracker:
         Returns:
         Returns:
             准备发送给前端的事件字典列表。
             准备发送给前端的事件字典列表。
         """
         """
-
         with self._lock:
         with self._lock:
             if self._cursor >= len(self._events):
             if self._cursor >= len(self._events):
                 return []
                 return []
@@ -146,7 +145,6 @@ class ToolCallTracker:
         Returns:
         Returns:
             包含所有工具调用事件的字典列表。
             包含所有工具调用事件的字典列表。
         """
         """
-
         with self._lock:
         with self._lock:
             return [
             return [
                 {
                 {
@@ -162,17 +160,16 @@ class ToolCallTracker:
                 for event in self._events
                 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:
         Args:
             sink: 接收事件字典的回调函数。
             sink: 接收事件字典的回调函数。
         """
         """
-
         self._event_sink = 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 = {
         payload = {
             "type": "tool_call",
             "type": "tool_call",
             "event_id": event.id,
             "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:
     def _attach_note_to_task(self, tasks: list[TodoItem], task_id: int, note_id: str) -> None:
         """使用笔记元数据更新匹配的 TODO 项目。"""
         """使用笔记元数据更新匹配的 TODO 项目。"""
-
         for task in tasks:
         for task in tasks:
             if task.id != task_id:
             if task.id != task_id:
                 continue
                 continue
@@ -208,9 +204,8 @@ class ToolCallTracker:
                 task.note_path = str(Path(self._notes_workspace) / f"{note_id}.md")
                 task.note_path = str(Path(self._notes_workspace) / f"{note_id}.md")
             break
             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。"""
         """尝试从工具参数推断 task_id。"""
-
         if not parameters:
         if not parameters:
             return None
             return None
 
 
@@ -235,7 +230,7 @@ class ToolCallTracker:
 
 
         return None
         return None
 
 
-    def _extract_note_id(self, response: str) -> Optional[str]:
+    def _extract_note_id(self, response: str) -> str | None:
         if not response:
         if not response:
             return None
             return None
 
 

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

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

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

@@ -47,12 +47,16 @@
       <!-- 2. Production View: 制作进度监控 -->
       <!-- 2. Production View: 制作进度监控 -->
       <section v-else-if="currentView === 'producing'" class="view-production" key="production">
       <section v-else-if="currentView === 'producing'" class="view-production" key="production">
         <div class="production-content">
         <div class="production-content">
+          <!-- 顶部:标题和控制 -->
           <header class="production-header">
           <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>
             <button class="cancel-btn" @click="cancelProduction">取消</button>
           </header>
           </header>
 
 
+          <!-- 阶段进度指示器 -->
           <div class="stage-monitor">
           <div class="stage-monitor">
             <div class="stage-step" :class="{ active: productionStage === 'research', completed: isStageCompleted('research') }">
             <div class="stage-step" :class="{ active: productionStage === 'research', completed: isStageCompleted('research') }">
               <div class="step-icon">🔍</div>
               <div class="step-icon">🔍</div>
@@ -101,18 +105,59 @@
               </div>
               </div>
             </div>
             </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>
         </div>
       </section>
       </section>
 
 
-      <!-- 3. Player View: 播放器与脚本 -->
+      <!-- 3. Player View: 独立播放器页面 -->
       <section v-else-if="currentView === 'player'" class="view-player" key="player">
       <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="album-art">
               <div class="vinyl-record" :class="{ spinning: isPlaying }">
               <div class="vinyl-record" :class="{ spinning: isPlaying }">
                 <div class="vinyl-label">DC</div>
                 <div class="vinyl-label">DC</div>
@@ -124,59 +169,29 @@
               <p>DeepCast 原创播客</p>
               <p>DeepCast 原创播客</p>
             </div>
             </div>
 
 
-            <div class="audio-controls">
+            <!-- 简单原生播放器 -->
+            <div class="simple-player-large">
               <audio 
               <audio 
                 ref="audioPlayer" 
                 ref="audioPlayer" 
                 :src="audioUrl" 
                 :src="audioUrl" 
-                @timeupdate="onTimeUpdate"
-                @ended="isPlaying = false"
+                controls
                 @play="isPlaying = true"
                 @play="isPlaying = true"
                 @pause="isPlaying = false"
                 @pause="isPlaying = false"
               ></audio>
               ></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>
 
 
-            <div class="report-toggle">
-              <button @click="showReport = !showReport">
-                {{ showReport ? '隐藏深度研究报告' : '查看深度研究报告' }}
-              </button>
-            </div>
+            <a :href="audioUrl" download class="download-btn-large">
+              ⬇️ 下载 MP3
+            </a>
           </div>
           </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>
           </div>
         </div>
         </div>
@@ -220,7 +235,9 @@ const isPlaying = ref(false);
 const currentTime = ref(0);
 const currentTime = ref(0);
 const duration = ref(0);
 const duration = ref(0);
 const progressPercent = computed(() => (duration.value ? (currentTime.value / duration.value) * 100 : 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
 // Research Progress State
 const totalTasks = ref(0);
 const totalTasks = ref(0);
@@ -343,6 +360,9 @@ async function startProduction() {
   audioProgress.total = 0;
   audioProgress.total = 0;
   audioProgress.role = "";
   audioProgress.role = "";
   currentStatusMessage.value = "正在初始化...";
   currentStatusMessage.value = "正在初始化...";
+  reportReady.value = false;
+  podcastReady.value = false;
+  showReport.value = true;
 
 
   abortController = new AbortController();
   abortController = new AbortController();
   
   
@@ -531,10 +551,11 @@ function handleStreamEvent(event: ResearchStreamEvent) {
       }
       }
   }
   }
 
 
-  // 5. Report Ready
+  // 5. Report Ready - 显示报告预览
   if (event.type === "final_report") {
   if (event.type === "final_report") {
     reportMarkdown.value = String(event.report);
     reportMarkdown.value = String(event.report);
-    currentStatusMessage.value = "深度研究报告已完成";
+    reportReady.value = true;
+    currentStatusMessage.value = "深度研究报告已完成,继续生成播客...";
     const reportLen = String(event.report).length;
     const reportLen = String(event.report).length;
     addLog(`📄 [REPORT] status=completed length=${reportLen} chars`);
     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") {
   if (event.type === "podcast_ready") {
     const payload = event as any;
     const payload = event as any;
     const filename = String(payload.file).split(/[\\/]/).pop();
     const filename = String(payload.file).split(/[\\/]/).pop();
     if (filename) {
     if (filename) {
       const baseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
       const baseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
       audioUrl.value = `${baseUrl}/output/${filename}`;
       audioUrl.value = `${baseUrl}/output/${filename}`;
+      podcastReady.value = true;
       currentStatusMessage.value = "🎉 播客制作完成!";
       currentStatusMessage.value = "🎉 播客制作完成!";
       addLog(`🎉 [PODCAST] status=ready file=${filename}`);
       addLog(`🎉 [PODCAST] status=ready file=${filename}`);
       productionStage.value = "done";
       productionStage.value = "done";
-      
-      setTimeout(() => {
-        currentView.value = "player";
-      }, 1500);
     }
     }
   }
   }
 
 
@@ -636,6 +654,27 @@ function resetApp() {
   isPlaying.value = false;
   isPlaying.value = false;
   currentStatusMessage.value = "";
   currentStatusMessage.value = "";
   stopWaitingAnimation();
   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
 // Audio Controls
@@ -850,13 +889,232 @@ select {
   cursor: not-allowed;
   cursor: not-allowed;
 }
 }
 
 
-/* --- Production View --- */
+/* --- Production View (上下布局) --- */
 .view-production {
 .view-production {
   overflow-y: auto;
   overflow-y: auto;
   width: 100%;
   width: 100%;
   display: block;
   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 {
 .production-content {
   max-width: 800px;
   max-width: 800px;
   margin: 0 auto;
   margin: 0 auto;
@@ -1274,46 +1532,43 @@ select {
   line-height: 1.5;
   line-height: 1.5;
 }
 }
 
 
-/* --- Player View --- */
+/* --- Player View (简化版) --- */
 .view-player {
 .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 {
 .back-home-btn {
-  align-self: flex-start;
   background: none;
   background: none;
   border: none;
   border: none;
   color: #64748b;
   color: #64748b;
   cursor: pointer;
   cursor: pointer;
   margin-bottom: 2rem;
   margin-bottom: 2rem;
+  font-size: 1rem;
 }
 }
 
 
 .back-home-btn:hover {
 .back-home-btn:hover {
   color: #fff;
   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;
   margin-bottom: 2rem;
+}
+
+.album-art {
+  width: 200px;
+  height: 200px;
+  margin: 0 auto 1.5rem;
   position: relative;
   position: relative;
 }
 }
 
 
@@ -1330,10 +1585,10 @@ select {
 }
 }
 
 
 .vinyl-record.spinning {
 .vinyl-record.spinning {
-  animation: spin 5s linear infinite;
+  animation: vinylSpin 5s linear infinite;
 }
 }
 
 
-@keyframes spin {
+@keyframes vinylSpin {
   from { transform: rotate(0deg); }
   from { transform: rotate(0deg); }
   to { transform: rotate(360deg); }
   to { transform: rotate(360deg); }
 }
 }
@@ -1353,7 +1608,7 @@ select {
 
 
 .track-info {
 .track-info {
   text-align: center;
   text-align: center;
-  margin-bottom: 2rem;
+  margin-bottom: 1.5rem;
 }
 }
 
 
 .track-info h3 {
 .track-info h3 {
@@ -1364,161 +1619,69 @@ select {
   color: #fff;
   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;
   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%;
   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;
   color: #94a3b8;
-  padding: 0.5rem 1rem;
-  border-radius: 20px;
-  font-size: 0.8rem;
+  font-size: 1rem;
   cursor: pointer;
   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-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 {
 .markdown-report {
@@ -1528,101 +1691,58 @@ select {
   line-height: 1.7;
   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;
   color: #60a5fa;
   border-bottom: 1px solid rgba(255, 255, 255, 0.1);
   border-bottom: 1px solid rgba(255, 255, 255, 0.1);
   padding-bottom: 0.5rem;
   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;
   color: #c084fc;
 }
 }
 
 
-.report-content :deep(h3) {
+.markdown-report :deep(h3) {
   font-size: 1.1rem;
   font-size: 1.1rem;
-  margin-top: 1.5rem;
-  margin-bottom: 0.8rem;
+  margin-top: 1.2rem;
+  margin-bottom: 0.6rem;
   color: #e2e8f0;
   color: #e2e8f0;
 }
 }
 
 
-.report-content :deep(p) {
-  margin-bottom: 1rem;
+.markdown-report :deep(p) {
+  margin-bottom: 0.8rem;
   color: #cbd5e1;
   color: #cbd5e1;
 }
 }
 
 
-.report-content :deep(ul),
-.report-content :deep(ol) {
+.markdown-report :deep(ul),
+.markdown-report :deep(ol) {
   padding-left: 1.5rem;
   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;
   color: #fff;
   font-weight: 600;
   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;
   border-left: 4px solid #60a5fa;
   padding-left: 1rem;
   padding-left: 1rem;
   margin: 1rem 0;
   margin: 1rem 0;
@@ -1633,6 +1753,15 @@ select {
   border-radius: 0 4px 4px 0;
   border-radius: 0 4px 4px 0;
 }
 }
 
 
+.markdown-report :deep(a) {
+  color: #60a5fa;
+  text-decoration: none;
+}
+
+.markdown-report :deep(a:hover) {
+  text-decoration: underline;
+}
+
 /* Transitions */
 /* Transitions */
 .fade-enter-active, .fade-leave-active {
 .fade-enter-active, .fade-leave-active {
   transition: opacity 0.3s ease;
   transition: opacity 0.3s ease;