Browse Source

Merge pull request #533 from huailishang/feature/agent-platform-base

[毕业设计] AgentPlatformBase - 双智能体任务平台
jjyaoao 1 month ago
parent
commit
61366453b9
56 changed files with 6567 additions and 0 deletions
  1. 65 0
      Co-creation-projects/huailishang-AgentPlatformBase/.env.example
  2. 6 0
      Co-creation-projects/huailishang-AgentPlatformBase/.gitignore
  3. 198 0
      Co-creation-projects/huailishang-AgentPlatformBase/README.md
  4. 19 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/README.md
  5. 18 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/__init__.py
  6. 551 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/agent.py
  7. 143 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/config.py
  8. 190 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/main.py
  9. 51 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/models.py
  10. 110 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/prompts.py
  11. 2 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/__init__.py
  12. 59 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/notes.py
  13. 160 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/planner.py
  14. 77 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/reporter.py
  15. 109 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/search.py
  16. 125 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/summarizer.py
  17. 16 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/text_processing.py
  18. 215 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/tool_events.py
  19. 84 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/utils.py
  20. 95 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/README.md
  21. 148 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/config/sources.json
  22. 38 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/config/sources_full.opml
  23. 20 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/main.py
  24. 11 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/scripts/run_daily.ps1
  25. 1 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/__init__.py
  26. 145 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/config.py
  27. 259 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/db.py
  28. 309 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/digest.py
  29. 129 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/extractor.py
  30. 128 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/feeds.py
  31. 151 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/llm.py
  32. 443 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/pipeline.py
  33. 254 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/ui_server.py
  34. 1 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/__init__.py
  35. 14 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/__init__.py
  36. 0 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/adapters/__init__.py
  37. 220 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/adapters/deep_research.py
  38. 251 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/adapters/rss_digest.py
  39. 31 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/base.py
  40. 26 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/profiles.py
  41. 34 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/registry.py
  42. 103 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/config.py
  43. 44 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/events.py
  44. 117 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/main.py
  45. 149 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/maintenance.py
  46. 74 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/models.py
  47. 5 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/tasks/__init__.py
  48. 17 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/tasks/batch.py
  49. 60 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/tasks/manager.py
  50. 56 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/tasks/runner.py
  51. 461 0
      Co-creation-projects/huailishang-AgentPlatformBase/frontend/app.js
  52. 61 0
      Co-creation-projects/huailishang-AgentPlatformBase/frontend/index.html
  53. 419 0
      Co-creation-projects/huailishang-AgentPlatformBase/frontend/styles.css
  54. 17 0
      Co-creation-projects/huailishang-AgentPlatformBase/main.py
  55. 11 0
      Co-creation-projects/huailishang-AgentPlatformBase/requirements.txt
  56. 67 0
      Co-creation-projects/huailishang-AgentPlatformBase/smoke_test.py

+ 65 - 0
Co-creation-projects/huailishang-AgentPlatformBase/.env.example

@@ -0,0 +1,65 @@
+APP_NAME=Agent Platform Base
+APP_HOST=127.0.0.1
+APP_PORT=8016
+
+# LLM settings. Fill these with your own provider values when needed.
+LLM_PROVIDER=
+LLM_MODEL_ID=
+LLM_API_KEY=
+LLM_BASE_URL=
+LLM_TIMEOUT=120
+
+# Search settings for the built-in deep research adapter.
+SEARCH_API=duckduckgo
+TAVILY_API_KEY=
+SERPAPI_API_KEY=
+MAX_WEB_RESEARCH_LOOPS=3
+FETCH_FULL_PAGE=true
+ENABLE_NOTES=true
+NOTES_WORKSPACE=./data/deep_research/notes
+PERSIST_RUNS=true
+RUN_WORKSPACE=./data/deep_research/runs
+CLEANUP_INTERMEDIATE_FILES=false
+REPORT_TASK_SUMMARY_CHARS=2400
+REPORT_SOURCES_LIMIT=5
+
+# Memory/vector settings reused from chapter15.
+EMBED_MODEL_TYPE=
+EMBED_MODEL_NAME=
+EMBED_API_KEY=
+EMBED_BASE_URL=
+QDRANT_URL=
+QDRANT_API_KEY=
+QDRANT_COLLECTION=hello_agents_vectors
+QDRANT_VECTOR_SIZE=384
+QDRANT_DISTANCE=cosine
+QDRANT_TIMEOUT=30
+
+NEO4J_URI=
+NEO4J_USERNAME=
+NEO4J_PASSWORD=
+NEO4J_DATABASE=neo4j
+
+# Built-in DeepResearch path. Leave empty to use agents/deep_research/src.
+CHAPTER14_BACKEND_PATH=./agents/deep_research/src
+
+# Built-in RSS digest agent paths.
+RSS_DIGEST_ROOT=./agents/rss_digest
+RSS_DIGEST_DATA_ROOT=./data/rss_digest
+RSS_FETCH_CONCURRENCY=10
+RSS_FETCH_TIMEOUT_SECONDS=15
+RSS_SOURCE_LIMIT=10
+RSS_ENTRIES_PER_SOURCE=5
+RSS_MAX_NEW_ARTICLES_PER_RUN=50
+RSS_AI_BATCH_SIZE=10
+RSS_AI_MAX_CONCURRENCY=2
+RSS_RELEVANCE_THRESHOLD=65
+RSS_MAX_SUMMARY_ARTICLES_PER_RUN=10
+RSS_MAX_DIGEST_ARTICLES=12
+
+# Lightweight artifact cleanup. Runs lazily when long-running agents are used.
+MAINTENANCE_CLEANUP_ENABLED=true
+MAINTENANCE_CLEANUP_INTERVAL_HOURS=6
+RESEARCH_RUN_RETENTION_DAYS=7
+RSS_DIGEST_RETENTION_DAYS=7
+RSS_CACHE_RETENTION_DAYS=7

+ 6 - 0
Co-creation-projects/huailishang-AgentPlatformBase/.gitignore

@@ -0,0 +1,6 @@
+.env
+__pycache__/
+*.pyc
+runs/
+notes/
+data/

+ 198 - 0
Co-creation-projects/huailishang-AgentPlatformBase/README.md

@@ -0,0 +1,198 @@
+# AgentPlatformBase - 双智能体任务平台
+
+`AgentPlatformBase` 是一个面向 Hello-Agents 第 16 章毕业项目的轻量智能体平台。它用 FastAPI 提供统一后端,用浏览器前端承载对话入口,并接入两个有明确业务价值的智能体:搜索员 `deep_research` 和资讯员 `rss_digest`。
+
+## 核心功能
+
+- 统一智能体注册表:后端通过 `AgentRegistry` 管理不同智能体。
+- 后台任务执行:长任务默认后台运行,前端轮询任务状态,不阻塞输入框。
+- 搜索员:内置 DeepResearchAgent,生成调研报告并保留运行产物和长期笔记。
+- 资讯员:拉取 RSS、抽取正文、调用 LLM 生成中文摘要,并渲染 HTML 简报。
+- 数据分区:所有智能体数据统一放在 `data/{agent_id}/`,便于清理和提交时忽略。
+
+## 项目结构
+
+```text
+agent_platform_base/
+  backend/
+    agents/
+      adapters/
+        deep_research.py
+        rss_digest.py
+      base.py
+      profiles.py
+      registry.py
+    memory/
+    tasks/
+    main.py
+    config.py
+    maintenance.py
+    events.py
+    models.py
+
+  frontend/
+    index.html
+    styles.css
+    app.js
+
+  agents/
+    deep_research/
+      README.md
+      src/
+        agent.py
+        config.py
+        services/
+    rss_digest/
+      src/rss_digest/
+      config/
+      scripts/
+      main.py
+      README.md
+
+  data/
+    deep_research/
+      runs/
+      notes/
+    rss_digest/
+      runs/
+      state/
+
+  .env.example
+  requirements.txt
+  smoke_test.py
+```
+
+目录规则:
+
+- `backend/`:平台后端,只放 API、任务、注册表、适配器和平台公共逻辑。
+- `frontend/`:单页前端工作台。
+- `agents/{agent_id}/`:具体智能体代码、配置和脚本。
+- `data/{agent_id}/runs/`:可清理的运行产物。
+- `data/{agent_id}/notes/`:长期保留的知识和笔记,仅有需要的智能体才创建。
+- `data/{agent_id}/state/`:持久状态,例如 RSS 去重数据库。
+
+## 技术栈
+
+- Python 3.10+
+- FastAPI / Uvicorn
+- Pydantic
+- hello-agents / OpenAI SDK / Tavily / DDGS
+- Requests / Python 标准库 RSS 与 HTML 解析
+- 原生 HTML、CSS、JavaScript
+
+## 快速开始
+
+```powershell
+cd Co-creation-projects\huailishang-AgentPlatformBase
+python -m pip install -r requirements.txt
+python main.py
+```
+
+访问:
+
+- 前端工作台:http://127.0.0.1:8016/app/
+- API 文档:http://127.0.0.1:8016/docs
+- 健康检查:http://127.0.0.1:8016/health
+
+## 使用示例
+
+前端输入框必须用 `@` 指定智能体:
+
+```text
+@deep_research 调研 AI Agent 平台架构
+@rss_digest 今日简报
+@rss_digest 强制刷新今日简报
+```
+
+如果当天已经生成 RSS HTML 简报,普通 `@rss_digest 今日简报` 会直接返回已有简报,避免重复拉取和重复消耗 LLM。输入包含“强制”“重新生成”“刷新”或 `force/refresh` 时会重新运行 RSS pipeline。
+
+## 运行机制
+
+```text
+POST /tasks
+POST /tasks/{task_id}/run        默认后台启动,立即返回 running
+GET  /tasks/{task_id}            前端轮询直到 completed / failed
+```
+
+同步调试可以使用:
+
+```text
+POST /tasks/{task_id}/run?background=false
+```
+
+任务完成后会在 `artifacts.elapsed_seconds` 记录总耗时。RSS 和 DeepResearch 还会记录更细的阶段耗时,便于后续优化。
+
+## RSS 默认配置
+
+```env
+RSS_SOURCE_LIMIT=10
+RSS_ENTRIES_PER_SOURCE=5
+RSS_MAX_NEW_ARTICLES_PER_RUN=50
+RSS_MAX_SUMMARY_ARTICLES_PER_RUN=10
+RSS_AI_MAX_CONCURRENCY=2
+RSS_RELEVANCE_THRESHOLD=65
+RSS_MAX_DIGEST_ARTICLES=12
+```
+
+RSS 后台日志只保留阶段级进度和最终统计,逐个 feed、逐篇文章、逐条摘要的过程日志不再打印到后台。
+
+## 清理策略
+
+清理逻辑在 `backend/maintenance.py`,长任务调用时惰性触发:
+
+- `RESEARCH_RUN_RETENTION_DAYS=7`:删除超过 7 天的搜索员运行产物。
+- `RSS_DIGEST_RETENTION_DAYS=7`:删除超过 7 天的 RSS HTML 简报。
+- `RSS_CACHE_RETENTION_DAYS=7`:删除超过 7 天的 RSS 原始 HTML、正文抽取和翻译缓存。
+- 不自动删除 `data/deep_research/notes`。
+- 不自动删除 `data/rss_digest/state/articles.json`。
+
+## 自检
+
+```powershell
+cd Co-creation-projects\huailishang-AgentPlatformBase
+python smoke_test.py
+```
+
+通过时输出:
+
+```text
+chapter16 platform smoke test passed
+```
+
+## 提交说明
+
+按第 16 章要求,最终提交版会整理到:
+
+```text
+Co-creation-projects/huailishang-AgentPlatformBase/
+```
+
+提交版不包含 `.env`、运行数据、缓存、视频、大模型文件或其它大文件,确保项目体积满足 5MB 要求。
+
+## 项目亮点
+
+- 平台层和智能体层分离,后续新增智能体只需要实现适配器并注册 profile。
+- 长耗时任务后台执行,前端体验不会被 RSS 抓取或 DeepResearch 调研阻塞。
+- RSS 使用轻量增量策略,默认每次最多处理 10 个源、50 篇正文、10 篇摘要,避免一次调用过慢。
+- 运行产物和长期知识统一归档到 `data/{agent_id}/`,提交时可以整体忽略。
+
+## 效果评估
+
+- `smoke_test.py` 覆盖健康检查、智能体列表、dry run、批量保护和任务执行基本链路。
+- 提交目录体积约 143KB,不包含运行数据和密钥,满足 5MB 限制。
+- RSS 后台日志已收敛为阶段级统计,避免逐篇文章刷屏。
+
+## 后续计划
+
+- 为 `deep_research` 增加更完整的前端报告查看页。
+- 为 RSS 简报增加前端筛选、收藏和历史归档入口。
+- 将任务事件持久化到 SQLite,支持服务重启后的任务历史查询。
+
+## 作者
+
+- GitHub 用户名目录:`huailishang-AgentPlatformBase`
+- 项目路径:`Co-creation-projects/huailishang-AgentPlatformBase/`
+
+## 许可证
+
+本项目用于 Hello-Agents 课程毕业设计提交,遵循仓库根目录许可证约束。

+ 19 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/README.md

@@ -0,0 +1,19 @@
+# deep_research
+
+`deep_research` 是 chapter16 平台内置的搜索调研智能体,源码位于:
+
+```text
+agents/deep_research/src/
+```
+
+这份源码来自 chapter14 的 DeepResearchAgent,并已内置到 chapter16 项目中。默认运行不再依赖 `code/chapter14`,因此只保留 `code/chapter16/agent_platform_base` 也可以运行搜索员。
+
+运行数据写入:
+
+```text
+data/deep_research/runs/
+data/deep_research/notes/
+```
+
+- `runs/`:单次运行过程产物,可按保留期清理。
+- `notes/`:研究笔记和索引,默认长期保留。

+ 18 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/__init__.py

@@ -0,0 +1,18 @@
+"""HelloAgents Deep Research - A deep research assistant powered by HelloAgents."""
+
+__version__ = "0.0.1"
+
+from .agent import DeepResearchAgent
+from .config import Configuration, SearchAPI
+from .models import SummaryState, SummaryStateInput, SummaryStateOutput, TodoItem
+
+__all__ = [
+    "DeepResearchAgent",
+    "Configuration",
+    "SearchAPI",
+    "SummaryState",
+    "SummaryStateInput",
+    "SummaryStateOutput",
+    "TodoItem",
+]
+

+ 551 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/agent.py

@@ -0,0 +1,551 @@
+"""Orchestrator coordinating the deep research workflow."""
+
+from __future__ import annotations
+
+import logging
+import re
+from pathlib import Path
+from queue import Empty, Queue
+from threading import Lock, Thread
+from typing import Any, Callable, Iterator
+
+from hello_agents import HelloAgentsLLM, ToolAwareSimpleAgent
+from hello_agents.tools import ToolRegistry
+from hello_agents.tools.builtin.note_tool import NoteTool
+
+from config import Configuration
+from prompts import (
+    report_writer_instructions,
+    task_summarizer_instructions,
+    todo_planner_system_prompt,
+)
+from models import SummaryState, SummaryStateOutput, TodoItem
+from services.planner import PlanningService
+from services.reporter import ReportingService
+from services.search import dispatch_search, prepare_research_context
+from services.summarizer import SummarizationService
+from services.tool_events import ToolCallTracker
+
+logger = logging.getLogger(__name__)
+
+
+class DeepResearchAgent:
+    """Coordinator orchestrating TODO-based research workflow using HelloAgents."""
+
+    def __init__(self, config: Configuration | None = None) -> None:
+        """Initialise the coordinator with configuration and shared tools."""
+        self.config = config or Configuration.from_env()
+        self.llm = self._init_llm()
+
+        self.note_tool = (
+            NoteTool(workspace=self.config.notes_workspace)
+            if self.config.enable_notes
+            else None
+        )
+        self.tools_registry: ToolRegistry | None = None
+        if self.note_tool:
+            registry = ToolRegistry()
+            registry.register_tool(self.note_tool)
+            self.tools_registry = registry
+
+        self._tool_tracker = ToolCallTracker(
+            self.config.notes_workspace if self.config.enable_notes else None
+        )
+        self._tool_event_sink_enabled = False
+        self._state_lock = Lock()
+
+        self.todo_agent = self._create_tool_aware_agent(
+            name="研究规划专家",
+            system_prompt=todo_planner_system_prompt.strip(),
+        )
+        self.report_agent = self._create_tool_aware_agent(
+            name="报告撰写专家",
+            system_prompt=report_writer_instructions.strip(),
+        )
+
+        self._summarizer_factory: Callable[[], ToolAwareSimpleAgent] = lambda: self._create_tool_aware_agent(  # noqa: E501
+            name="任务总结专家",
+            system_prompt=task_summarizer_instructions.strip(),
+        )
+
+        self.planner = PlanningService(self.todo_agent, self.config)
+        self.summarizer = SummarizationService(self._summarizer_factory, self.config)
+        self.reporting = ReportingService(self.report_agent, self.config)
+        self._last_search_notices: list[str] = []
+
+    # ------------------------------------------------------------------
+    # Public API
+    # ------------------------------------------------------------------
+    def _init_llm(self) -> HelloAgentsLLM:
+        """Instantiate HelloAgentsLLM following configuration preferences."""
+        llm_kwargs: dict[str, Any] = {"temperature": 0.0}
+
+        model_id = self.config.llm_model_id or self.config.local_llm
+        if model_id:
+            llm_kwargs["model"] = model_id
+
+        provider = (self.config.llm_provider or "").strip()
+        if provider:
+            llm_kwargs["provider"] = provider
+
+        if provider == "ollama":
+            llm_kwargs["base_url"] = self.config.sanitized_ollama_url()
+            if self.config.llm_api_key:
+                llm_kwargs["api_key"] = self.config.llm_api_key
+            else:
+                llm_kwargs["api_key"] = "ollama"
+        elif provider == "lmstudio":
+            llm_kwargs["base_url"] = self.config.lmstudio_base_url
+            if self.config.llm_api_key:
+                llm_kwargs["api_key"] = self.config.llm_api_key
+        else:
+            if self.config.llm_base_url:
+                llm_kwargs["base_url"] = self.config.llm_base_url
+            if self.config.llm_api_key:
+                llm_kwargs["api_key"] = self.config.llm_api_key
+
+        return HelloAgentsLLM(**llm_kwargs)
+
+    def _create_tool_aware_agent(self, *, name: str, system_prompt: str) -> ToolAwareSimpleAgent:
+        """Instantiate a ToolAwareSimpleAgent sharing tool registry and tracker."""
+        return ToolAwareSimpleAgent(
+            name=name,
+            llm=self.llm,
+            system_prompt=system_prompt,
+            enable_tool_calling=self.tools_registry is not None,
+            tool_registry=self.tools_registry,
+            tool_call_listener=self._tool_tracker.record,
+        )
+
+    def _set_tool_event_sink(self, sink: Callable[[dict[str, Any]], None] | None) -> None:
+        """Enable or disable immediate tool event callbacks."""
+        self._tool_event_sink_enabled = sink is not None
+        self._tool_tracker.set_event_sink(sink)
+
+    def run(self, topic: str) -> SummaryStateOutput:
+        """Execute the research workflow and return the final report."""
+        state = SummaryState(research_topic=topic)
+        state.todo_items = self.planner.plan_todo_list(state)
+        self._drain_tool_events(state)
+
+        if not state.todo_items:
+            logger.info("No TODO items generated; falling back to single task")
+            state.todo_items = [self.planner.create_fallback_task(state)]
+
+        for task in state.todo_items:
+            self._execute_task(state, task, emit_stream=False)
+
+        report = self.reporting.generate_report(state)
+        self._drain_tool_events(state)
+        state.structured_report = report
+        state.running_summary = report
+        self._persist_final_report(state, report)
+
+        return SummaryStateOutput(
+            running_summary=report,
+            report_markdown=report,
+            todo_items=state.todo_items,
+        )
+
+    def run_stream(self, topic: str) -> Iterator[dict[str, Any]]:
+        """Execute the workflow yielding incremental progress events."""
+        state = SummaryState(research_topic=topic)
+        logger.debug("Starting streaming research: topic=%s", topic)
+        yield {"type": "status", "message": "初始化研究流程"}
+
+        state.todo_items = self.planner.plan_todo_list(state)
+        for event in self._drain_tool_events(state, step=0):
+            yield event
+        if not state.todo_items:
+            state.todo_items = [self.planner.create_fallback_task(state)]
+
+        channel_map: dict[int, dict[str, Any]] = {}
+        for index, task in enumerate(state.todo_items, start=1):
+            token = f"task_{task.id}"
+            task.stream_token = token
+            channel_map[task.id] = {"step": index, "token": token}
+
+        yield {
+            "type": "todo_list",
+            "tasks": [self._serialize_task(t) for t in state.todo_items],
+            "step": 0,
+        }
+
+        event_queue: Queue[dict[str, Any]] = Queue()
+
+        def enqueue(
+            event: dict[str, Any],
+            *,
+            task: TodoItem | None = None,
+            step_override: int | None = None,
+        ) -> None:
+            payload = dict(event)
+            target_task_id = payload.get("task_id")
+            if task is not None:
+                target_task_id = task.id
+                payload["task_id"] = task.id
+
+            channel = channel_map.get(target_task_id) if target_task_id is not None else None
+            if channel:
+                payload.setdefault("step", channel["step"])
+                payload["stream_token"] = channel["token"]
+            if step_override is not None:
+                payload["step"] = step_override
+            event_queue.put(payload)
+
+        def tool_event_sink(event: dict[str, Any]) -> None:
+            enqueue(event)
+
+        self._set_tool_event_sink(tool_event_sink)
+
+        threads: list[Thread] = []
+
+        def worker(task: TodoItem, step: int) -> None:
+            try:
+                enqueue(
+                    {
+                        "type": "task_status",
+                        "task_id": task.id,
+                        "status": "in_progress",
+                        "title": task.title,
+                        "intent": task.intent,
+                        "note_id": task.note_id,
+                        "note_path": task.note_path,
+                    },
+                    task=task,
+                )
+
+                for event in self._execute_task(state, task, emit_stream=True, step=step):
+                    enqueue(event, task=task)
+            except Exception as exc:  # pragma: no cover - defensive guardrail
+                logger.exception("Task execution failed", exc_info=exc)
+                enqueue(
+                    {
+                        "type": "task_status",
+                        "task_id": task.id,
+                        "status": "failed",
+                        "detail": str(exc),
+                        "title": task.title,
+                        "intent": task.intent,
+                        "note_id": task.note_id,
+                        "note_path": task.note_path,
+                    },
+                    task=task,
+                )
+            finally:
+                enqueue({"type": "__task_done__", "task_id": task.id})
+
+        for task in state.todo_items:
+            step = channel_map.get(task.id, {}).get("step", 0)
+            thread = Thread(target=worker, args=(task, step), daemon=True)
+            threads.append(thread)
+            thread.start()
+
+        active_workers = len(state.todo_items)
+        finished_workers = 0
+
+        try:
+            while finished_workers < active_workers:
+                event = event_queue.get()
+                if event.get("type") == "__task_done__":
+                    finished_workers += 1
+                    continue
+                yield event
+
+            while True:
+                try:
+                    event = event_queue.get_nowait()
+                except Empty:
+                    break
+                if event.get("type") != "__task_done__":
+                    yield event
+        finally:
+            self._set_tool_event_sink(None)
+            for thread in threads:
+                thread.join()
+
+        report = self.reporting.generate_report(state)
+        final_step = len(state.todo_items) + 1
+        for event in self._drain_tool_events(state, step=final_step):
+            yield event
+        state.structured_report = report
+        state.running_summary = report
+
+        note_event = self._persist_final_report(state, report)
+        if note_event:
+            yield note_event
+
+        yield {
+            "type": "final_report",
+            "report": report,
+            "note_id": state.report_note_id,
+            "note_path": state.report_note_path,
+        }
+        yield {"type": "done"}
+
+    # ------------------------------------------------------------------
+    # Execution helpers
+    # ------------------------------------------------------------------
+    def _execute_task(
+        self,
+        state: SummaryState,
+        task: TodoItem,
+        *,
+        emit_stream: bool,
+        step: int | None = None,
+    ) -> Iterator[dict[str, Any]]:
+        """Run search + summarization for a single task."""
+        task.status = "in_progress"
+
+        search_result, notices, answer_text, backend = dispatch_search(
+            task.query,
+            self.config,
+            state.research_loop_count,
+        )
+        self._last_search_notices = notices
+        task.notices = notices
+
+        if emit_stream:
+            for event in self._drain_tool_events(state, step=step):
+                yield event
+        else:
+            self._drain_tool_events(state)
+
+        if notices and emit_stream:
+            for notice in notices:
+                if notice:
+                    yield {
+                        "type": "status",
+                        "message": notice,
+                        "task_id": task.id,
+                        "step": step,
+                    }
+
+        if not search_result or not search_result.get("results"):
+            task.status = "skipped"
+            if emit_stream:
+                for event in self._drain_tool_events(state, step=step):
+                    yield event
+                yield {
+                    "type": "task_status",
+                    "task_id": task.id,
+                    "status": "skipped",
+                    "title": task.title,
+                    "intent": task.intent,
+                    "note_id": task.note_id,
+                    "note_path": task.note_path,
+                    "step": step,
+                }
+            else:
+                self._drain_tool_events(state)
+            return
+        else:
+            if not emit_stream:
+                self._drain_tool_events(state)
+
+        sources_summary, context = prepare_research_context(
+            search_result,
+            answer_text,
+            self.config,
+        )
+
+        task.sources_summary = sources_summary
+
+        with self._state_lock:
+            state.web_research_results.append(context)
+            state.sources_gathered.append(sources_summary)
+            state.research_loop_count += 1
+
+        summary_text: str | None = None
+
+        if emit_stream:
+            for event in self._drain_tool_events(state, step=step):
+                yield event
+            yield {
+                "type": "sources",
+                "task_id": task.id,
+                "latest_sources": sources_summary,
+                "raw_context": context,
+                "step": step,
+                "backend": backend,
+                "note_id": task.note_id,
+                "note_path": task.note_path,
+            }
+
+            summary_stream, summary_getter = self.summarizer.stream_task_summary(state, task, context)
+            try:
+                for event in self._drain_tool_events(state, step=step):
+                    yield event
+                for chunk in summary_stream:
+                    if chunk:
+                        yield {
+                            "type": "task_summary_chunk",
+                            "task_id": task.id,
+                            "content": chunk,
+                            "note_id": task.note_id,
+                            "step": step,
+                        }
+                    for event in self._drain_tool_events(state, step=step):
+                        yield event
+            finally:
+                summary_text = summary_getter()
+        else:
+            summary_text = self.summarizer.summarize_task(state, task, context)
+            self._drain_tool_events(state)
+
+        task.summary = summary_text.strip() if summary_text else "暂无可用信息"
+        task.status = "completed"
+
+        if emit_stream:
+            for event in self._drain_tool_events(state, step=step):
+                yield event
+            yield {
+                "type": "task_status",
+                "task_id": task.id,
+                "status": "completed",
+                "summary": task.summary,
+                "sources_summary": task.sources_summary,
+                "note_id": task.note_id,
+                "note_path": task.note_path,
+                "step": step,
+            }
+        else:
+            self._drain_tool_events(state)
+
+    def _drain_tool_events(
+        self,
+        state: SummaryState,
+        *,
+        step: int | None = None,
+    ) -> list[dict[str, Any]]:
+        """Proxy to the shared tool call tracker."""
+        events = self._tool_tracker.drain(state, step=step)
+        if self._tool_event_sink_enabled:
+            return []
+        return events
+
+    @property
+    def _tool_call_events(self) -> list[dict[str, Any]]:
+        """Expose recorded tool events for legacy integrations."""
+        return self._tool_tracker.as_dicts()
+
+    def _serialize_task(self, task: TodoItem) -> dict[str, Any]:
+        """Convert task dataclass to serializable dict for frontend."""
+        return {
+            "id": task.id,
+            "title": task.title,
+            "intent": task.intent,
+            "query": task.query,
+            "status": task.status,
+            "summary": task.summary,
+            "sources_summary": task.sources_summary,
+            "note_id": task.note_id,
+            "note_path": task.note_path,
+            "stream_token": task.stream_token,
+        }
+
+    def _persist_final_report(self, state: SummaryState, report: str) -> dict[str, Any] | None:
+        if not self.note_tool or not report or not report.strip():
+            return None
+
+        note_title = f"研究报告:{state.research_topic}".strip() or "研究报告"
+        tags = ["deep_research", "report"]
+        content = report.strip()
+
+        note_id = self._find_existing_report_note_id(state)
+        response = ""
+
+        if note_id:
+            response = self.note_tool.run(
+                {
+                    "action": "update",
+                    "note_id": note_id,
+                    "title": note_title,
+                    "note_type": "conclusion",
+                    "tags": tags,
+                    "content": content,
+                }
+            )
+            if response.startswith("❌"):
+                note_id = None
+
+        if not note_id:
+            response = self.note_tool.run(
+                {
+                    "action": "create",
+                    "title": note_title,
+                    "note_type": "conclusion",
+                    "tags": tags,
+                    "content": content,
+                }
+            )
+            note_id = self._extract_note_id_from_text(response)
+
+        if not note_id:
+            return None
+
+        state.report_note_id = note_id
+        if self.config.notes_workspace:
+            note_path = Path(self.config.notes_workspace) / f"{note_id}.md"
+            state.report_note_path = str(note_path)
+        else:
+            note_path = None
+
+        payload = {
+            "type": "report_note",
+            "note_id": note_id,
+            "title": note_title,
+            "content": content,
+        }
+        if note_path:
+            payload["note_path"] = str(note_path)
+
+        return payload
+
+    def _find_existing_report_note_id(self, state: SummaryState) -> str | None:
+        if state.report_note_id:
+            return state.report_note_id
+
+        for event in reversed(self._tool_tracker.as_dicts()):
+            if event.get("tool") != "note":
+                continue
+
+            parameters = event.get("parsed_parameters") or {}
+            if not isinstance(parameters, dict):
+                continue
+
+            action = parameters.get("action")
+            if action not in {"create", "update"}:
+                continue
+
+            note_type = parameters.get("note_type")
+            if note_type != "conclusion":
+                title = parameters.get("title")
+                if not (isinstance(title, str) and title.startswith("研究报告")):
+                    continue
+
+            note_id = parameters.get("note_id")
+            if not note_id:
+                note_id = self._tool_tracker._extract_note_id(event.get("result", ""))  # type: ignore[attr-defined]
+
+            if note_id:
+                return note_id
+
+        return None
+
+    @staticmethod
+    def _extract_note_id_from_text(response: str) -> str | None:
+        if not response:
+            return None
+
+        match = re.search(r"ID:\s*([^\n]+)", response)
+        if not match:
+            return None
+
+        return match.group(1).strip()
+
+
+def run_deep_research(topic: str, config: Configuration | None = None) -> SummaryStateOutput:
+    """Convenience function mirroring the class-based API."""
+    agent = DeepResearchAgent(config=config)
+    return agent.run(topic)

+ 143 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/config.py

@@ -0,0 +1,143 @@
+import os
+from enum import Enum
+from typing import Any, Optional
+
+from pydantic import BaseModel, Field
+
+
+class SearchAPI(Enum):
+    PERPLEXITY = "perplexity"
+    TAVILY = "tavily"
+    DUCKDUCKGO = "duckduckgo"
+    SEARXNG = "searxng"
+    ADVANCED = "advanced"
+
+
+class Configuration(BaseModel):
+    """Configuration options for the deep research assistant."""
+
+    max_web_research_loops: int = Field(
+        default=3,
+        title="Research Depth",
+        description="Number of research iterations to perform",
+    )
+    local_llm: str = Field(
+        default="llama3.2",
+        title="Local Model Name",
+        description="Name of the locally hosted LLM (Ollama/LMStudio)",
+    )
+    llm_provider: str = Field(
+        default="ollama",
+        title="LLM Provider",
+        description="Provider identifier (ollama, lmstudio, or custom)",
+    )
+    search_api: SearchAPI = Field(
+        default=SearchAPI.DUCKDUCKGO,
+        title="Search API",
+        description="Web search API to use",
+    )
+    enable_notes: bool = Field(
+        default=True,
+        title="Enable Notes",
+        description="Whether to store task progress in NoteTool",
+    )
+    notes_workspace: str = Field(
+        default="./notes",
+        title="Notes Workspace",
+        description="Directory for NoteTool to persist task notes",
+    )
+    fetch_full_page: bool = Field(
+        default=True,
+        title="Fetch Full Page",
+        description="Include the full page content in the search results",
+    )
+    ollama_base_url: str = Field(
+        default="http://localhost:11434",
+        title="Ollama Base URL",
+        description="Base URL for Ollama API (without /v1 suffix)",
+    )
+    lmstudio_base_url: str = Field(
+        default="http://localhost:1234/v1",
+        title="LMStudio Base URL",
+        description="Base URL for LMStudio OpenAI-compatible API",
+    )
+    strip_thinking_tokens: bool = Field(
+        default=True,
+        title="Strip Thinking Tokens",
+        description="Whether to strip <think> tokens from model responses",
+    )
+    use_tool_calling: bool = Field(
+        default=False,
+        title="Use Tool Calling",
+        description="Use tool calling instead of JSON mode for structured output",
+    )
+    llm_api_key: Optional[str] = Field(
+        default=None,
+        title="LLM API Key",
+        description="Optional API key when using custom OpenAI-compatible services",
+    )
+    llm_base_url: Optional[str] = Field(
+        default=None,
+        title="LLM Base URL",
+        description="Optional base URL when using custom OpenAI-compatible services",
+    )
+    llm_model_id: Optional[str] = Field(
+        default=None,
+        title="LLM Model ID",
+        description="Optional model identifier for custom OpenAI-compatible services",
+    )
+
+    @classmethod
+    def from_env(cls, overrides: Optional[dict[str, Any]] = None) -> "Configuration":
+        """Create a configuration object using environment variables and overrides."""
+
+        raw_values: dict[str, Any] = {}
+
+        # Load values from environment variables based on field names
+        for field_name in cls.model_fields.keys():
+            env_key = field_name.upper()
+            if env_key in os.environ:
+                raw_values[field_name] = os.environ[env_key]
+
+        # Additional mappings for explicit env names
+        env_aliases = {
+            "local_llm": os.getenv("LOCAL_LLM"),
+            "llm_provider": os.getenv("LLM_PROVIDER"),
+            "llm_api_key": os.getenv("LLM_API_KEY"),
+            "llm_model_id": os.getenv("LLM_MODEL_ID"),
+            "llm_base_url": os.getenv("LLM_BASE_URL"),
+            "lmstudio_base_url": os.getenv("LMSTUDIO_BASE_URL"),
+            "ollama_base_url": os.getenv("OLLAMA_BASE_URL"),
+            "max_web_research_loops": os.getenv("MAX_WEB_RESEARCH_LOOPS"),
+            "fetch_full_page": os.getenv("FETCH_FULL_PAGE"),
+            "strip_thinking_tokens": os.getenv("STRIP_THINKING_TOKENS"),
+            "use_tool_calling": os.getenv("USE_TOOL_CALLING"),
+            "search_api": os.getenv("SEARCH_API"),
+            "enable_notes": os.getenv("ENABLE_NOTES"),
+            "notes_workspace": os.getenv("NOTES_WORKSPACE"),
+        }
+
+        for key, value in env_aliases.items():
+            if value is not None:
+                raw_values.setdefault(key, value)
+
+        if overrides:
+            for key, value in overrides.items():
+                if value is not None:
+                    raw_values[key] = value
+
+        return cls(**raw_values)
+
+    def sanitized_ollama_url(self) -> str:
+        """Ensure Ollama base URL includes the /v1 suffix required by OpenAI clients."""
+
+        base = self.ollama_base_url.rstrip("/")
+        if not base.endswith("/v1"):
+            base = f"{base}/v1"
+        return base
+
+    def resolved_model(self) -> Optional[str]:
+        """Best-effort resolution of the model identifier to use."""
+
+        return self.llm_model_id or self.local_llm
+

+ 190 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/main.py

@@ -0,0 +1,190 @@
+"""FastAPI entrypoint exposing the DeepResearchAgent via HTTP."""
+
+from __future__ import annotations
+
+import json
+import sys
+from typing import Any, Dict, Iterator, Optional
+
+from fastapi import FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import StreamingResponse
+from loguru import logger
+from pydantic import BaseModel, Field
+
+from config import Configuration, SearchAPI
+from agent import DeepResearchAgent
+
+# 添加控制台日志处理程序
+logger.add(
+    sys.stderr,
+    level="INFO",
+    format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <4}</level> | <cyan>using_function:{function}</cyan> | <cyan>{file}:{line}</cyan> | <level>{message}</level>",
+    colorize=True,
+)
+
+
+# 添加错误日志文件处理程序
+logger.add(
+    sink=sys.stderr,
+    level="ERROR",
+    format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <4}</level> | <cyan>using_function:{function}</cyan> | <cyan>{file}:{line}</cyan> | <level>{message}</level>",
+    colorize=True,
+)
+
+
+class ResearchRequest(BaseModel):
+    """Payload for triggering a research run."""
+
+    topic: str = Field(..., description="Research topic supplied by the user")
+    search_api: SearchAPI | None = Field(
+        default=None,
+        description="Override the default search backend configured via env",
+    )
+
+
+class ResearchResponse(BaseModel):
+    """HTTP response containing the generated report and structured tasks."""
+
+    report_markdown: str = Field(
+        ..., description="Markdown-formatted research report including sections"
+    )
+    todo_items: list[dict[str, Any]] = Field(
+        default_factory=list,
+        description="Structured TODO items with summaries and sources",
+    )
+
+
+def _mask_secret(value: Optional[str], visible: int = 4) -> str:
+    """Mask sensitive tokens while keeping leading and trailing characters."""
+    if not value:
+        return "unset"
+
+    if len(value) <= visible * 2:
+        return "*" * len(value)
+
+    return f"{value[:visible]}...{value[-visible:]}"
+
+
+def _build_config(payload: ResearchRequest) -> Configuration:
+    overrides: Dict[str, Any] = {}
+
+    if payload.search_api is not None:
+        overrides["search_api"] = payload.search_api
+
+    return Configuration.from_env(overrides=overrides)
+
+
+def create_app() -> FastAPI:
+    app = FastAPI(title="HelloAgents Deep Researcher")
+
+    app.add_middleware(
+        CORSMiddleware,
+        allow_origins=["*"],
+        allow_credentials=True,
+        allow_methods=["*"],
+        allow_headers=["*"],
+    )
+
+    @app.on_event("startup")
+    def log_startup_configuration() -> None:
+        config = Configuration.from_env()
+
+        if config.llm_provider == "ollama":
+            base_url = config.sanitized_ollama_url()
+        elif config.llm_provider == "lmstudio":
+            base_url = config.lmstudio_base_url
+        else:
+            base_url = config.llm_base_url or "unset"
+
+        logger.info(
+            "DeepResearch configuration loaded: provider=%s model=%s base_url=%s search_api=%s "
+            "max_loops=%s fetch_full_page=%s tool_calling=%s strip_thinking=%s api_key=%s",
+            config.llm_provider,
+            config.resolved_model() or "unset",
+            base_url,
+            (config.search_api.value if isinstance(config.search_api, SearchAPI) else config.search_api),
+            config.max_web_research_loops,
+            config.fetch_full_page,
+            config.use_tool_calling,
+            config.strip_thinking_tokens,
+            _mask_secret(config.llm_api_key),
+        )
+
+    @app.get("/healthz")
+    def health_check() -> Dict[str, str]:
+        return {"status": "ok"}
+
+    @app.post("/research", response_model=ResearchResponse)
+    def run_research(payload: ResearchRequest) -> ResearchResponse:
+        try:
+            config = _build_config(payload)
+            agent = DeepResearchAgent(config=config)
+            result = agent.run(payload.topic)
+        except ValueError as exc:  # Likely due to unsupported configuration
+            raise HTTPException(status_code=400, detail=str(exc)) from exc
+        except Exception as exc:  # pragma: no cover - defensive guardrail
+            raise HTTPException(status_code=500, detail="Research failed") from exc
+
+        todo_payload = [
+            {
+                "id": item.id,
+                "title": item.title,
+                "intent": item.intent,
+                "query": item.query,
+                "status": item.status,
+                "summary": item.summary,
+                "sources_summary": item.sources_summary,
+                "note_id": item.note_id,
+                "note_path": item.note_path,
+            }
+            for item in result.todo_items
+        ]
+
+        return ResearchResponse(
+            report_markdown=(result.report_markdown or result.running_summary or ""),
+            todo_items=todo_payload,
+        )
+
+    @app.post("/research/stream")
+    def stream_research(payload: ResearchRequest) -> StreamingResponse:
+        try:
+            config = _build_config(payload)
+            agent = DeepResearchAgent(config=config)
+        except ValueError as exc:
+            raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+        def event_iterator() -> Iterator[str]:
+            try:
+                for event in agent.run_stream(payload.topic):
+                    yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
+            except Exception as exc:  # pragma: no cover - defensive guardrail
+                logger.exception("Streaming research failed")
+                error_payload = {"type": "error", "detail": str(exc)}
+                yield f"data: {json.dumps(error_payload, ensure_ascii=False)}\n\n"
+
+        return StreamingResponse(
+            event_iterator(),
+            media_type="text/event-stream",
+            headers={
+                "Cache-Control": "no-cache",
+                "Connection": "keep-alive",
+            },
+        )
+
+    return app
+
+
+app = create_app()
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run(
+        "main:app",
+        host="0.0.0.0",
+        port=8000,
+        reload=True,
+        log_level="info"
+    )

+ 51 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/models.py

@@ -0,0 +1,51 @@
+"""State models used by the deep research workflow."""
+
+import operator
+from dataclasses import dataclass, field
+from typing import List, Optional
+
+from typing_extensions import Annotated
+
+
+@dataclass(kw_only=True)
+class TodoItem:
+    """单个待办任务项。"""
+
+    id: int
+    title: str
+    intent: str
+    query: str
+    status: str = field(default="pending")
+    summary: Optional[str] = field(default=None)
+    sources_summary: Optional[str] = field(default=None)
+    notices: list[str] = field(default_factory=list)
+    note_id: Optional[str] = field(default=None)
+    note_path: Optional[str] = field(default=None)
+    stream_token: Optional[str] = field(default=None)
+
+
+@dataclass(kw_only=True)
+class SummaryState:
+    research_topic: str = field(default=None)  # Report topic
+    search_query: str = field(default=None)  # Deprecated placeholder
+    web_research_results: Annotated[list, operator.add] = field(default_factory=list)
+    sources_gathered: Annotated[list, operator.add] = field(default_factory=list)
+    research_loop_count: int = field(default=0)  # Research loop count
+    running_summary: str = field(default=None)  # Legacy summary field
+    todo_items: Annotated[list, operator.add] = field(default_factory=list)
+    structured_report: Optional[str] = field(default=None)
+    report_note_id: Optional[str] = field(default=None)
+    report_note_path: Optional[str] = field(default=None)
+
+
+@dataclass(kw_only=True)
+class SummaryStateInput:
+    research_topic: str = field(default=None)  # Report topic
+
+
+@dataclass(kw_only=True)
+class SummaryStateOutput:
+    running_summary: str = field(default=None)  # Backward-compatible文本
+    report_markdown: Optional[str] = field(default=None)
+    todo_items: List[TodoItem] = field(default_factory=list)
+

+ 110 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/prompts.py

@@ -0,0 +1,110 @@
+from datetime import datetime
+
+
+# Get current date in a readable format
+def get_current_date():
+    return datetime.now().strftime("%B %d, %Y")
+
+
+
+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>
+"""
+
+
+todo_planner_instructions = """
+
+<CONTEXT>
+当前日期:{current_date}
+研究主题:{research_topic}
+</CONTEXT>
+
+<FORMAT>
+请严格以 JSON 格式回复:
+{{
+  "tasks": [
+    {{
+      "title": "任务名称(10字内,突出重点)",
+      "intent": "任务要解决的核心问题,用1-2句描述",
+      "query": "建议使用的检索关键词"
+    }}
+  ]
+}}
+</FORMAT>
+
+如果主题信息不足以规划任务,请输出空数组:{{"tasks": []}}。必要时使用笔记工具记录你的思考过程。
+"""
+
+
+task_summarizer_instructions = """
+你是一名研究执行专家,请基于给定的上下文,为特定任务生成要点总结,对内容进行详尽且细致的总结而不是走马观花,需要勇于创新、打破常规思维,并尽可能多维度,从原理、应用、优缺点、工程实践、对比、历史演变等角度进行拓展。
+
+<GOAL>
+1. 针对任务意图梳理 3-5 条关键发现;
+2. 清晰说明每条发现的含义与价值,可引用事实数据;
+</GOAL>
+
+<NOTES>
+- 任务笔记由规划专家创建,笔记 ID 会在调用时提供;请先调用 `[TOOL_CALL:note:{"action":"read","note_id":"<note_id>"}]` 获取最新状态。
+- 更新任务总结后,使用 `[TOOL_CALL:note:{"action":"update","note_id":"<note_id>","task_id":{task_id},"title":"任务 {task_id}: …","note_type":"task_state","tags":["deep_research","task_{task_id}"],"content":"..."}]` 写回笔记,保持原有结构并追加新信息。
+- 若未找到笔记 ID,请先创建并在 `tags` 中包含 `task_{task_id}` 后再继续。
+</NOTES>
+
+<FORMAT>
+- 使用 Markdown 输出;
+- 以小节标题开头:"任务总结";
+- 关键发现使用有序或无序列表表达;
+- 若任务无有效结果,输出"暂无可用信息"。
+- 最终呈现给用户的总结中禁止包含 `[TOOL_CALL:...]` 指令。
+</FORMAT>
+"""
+
+
+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>
+"""

+ 2 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/__init__.py

@@ -0,0 +1,2 @@
+"""Domain services for the deep researcher workflow."""
+

+ 59 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/notes.py

@@ -0,0 +1,59 @@
+"""Helpers for coordinating note tool usage instructions."""
+
+from __future__ import annotations
+
+import json
+
+from models import TodoItem
+
+
+def build_note_guidance(task: TodoItem) -> str:
+    """Generate note tool usage guidance for a specific task."""
+
+    tags_list = ["deep_research", f"task_{task.id}"]
+    tags_literal = json.dumps(tags_list, ensure_ascii=False)
+
+    if task.note_id:
+        read_payload = json.dumps({"action": "read", "note_id": task.note_id}, ensure_ascii=False)
+        update_payload = json.dumps(
+            {
+                "action": "update",
+                "note_id": task.note_id,
+                "task_id": task.id,
+                "title": f"任务 {task.id}: {task.title}",
+                "note_type": "task_state",
+                "tags": tags_list,
+                "content": "请将本轮新增信息补充到任务概览中",
+            },
+            ensure_ascii=False,
+        )
+
+        return (
+            "笔记协作指引:\n"
+            f"- 当前任务笔记 ID:{task.note_id}。\n"
+            f"- 在书写总结前必须调用:[TOOL_CALL:note:{read_payload}] 获取最新内容。\n"
+            f"- 完成分析后调用:[TOOL_CALL:note:{update_payload}] 同步增量信息。\n"
+            "- 更新时保持原有段落结构,新增内容请在对应段落中补充。\n"
+            f"- 建议 tags 保持为 {tags_literal},保证其他 Agent 可快速定位。\n"
+            "- 成功同步到笔记后,再输出面向用户的总结。\n"
+        )
+
+    create_payload = json.dumps(
+        {
+            "action": "create",
+            "task_id": task.id,
+            "title": f"任务 {task.id}: {task.title}",
+            "note_type": "task_state",
+            "tags": tags_list,
+            "content": "请记录任务概览、来源概览",
+        },
+        ensure_ascii=False,
+    )
+
+    return (
+        "笔记协作指引:\n"
+        f"- 当前任务尚未建立笔记,请先调用:[TOOL_CALL:note:{create_payload}]。\n"
+        "- 创建成功后记录返回的 note_id,并在后续所有更新中复用。\n"
+        "- 同步笔记后,再输出面向用户的总结。\n"
+    )
+

+ 160 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/planner.py

@@ -0,0 +1,160 @@
+"""Service responsible for converting the research topic into actionable tasks."""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+from typing import Any, List, Optional
+
+from hello_agents import ToolAwareSimpleAgent
+
+from models import SummaryState, TodoItem
+from config import Configuration
+from prompts import get_current_date, todo_planner_instructions
+from utils import strip_thinking_tokens
+
+logger = logging.getLogger(__name__)
+
+TOOL_CALL_PATTERN = re.compile(
+    r"\[TOOL_CALL:(?P<tool>[^:]+):(?P<body>[^\]]+)\]",
+    re.IGNORECASE,
+)
+
+class PlanningService:
+    """Wraps the planner agent to produce structured TODO items."""
+
+    def __init__(self, planner_agent: ToolAwareSimpleAgent, config: Configuration) -> None:
+        self._agent = planner_agent
+        self._config = config
+
+    def plan_todo_list(self, state: SummaryState) -> List[TodoItem]:
+        """Ask the planner agent to break the topic into actionable tasks."""
+
+        prompt = todo_planner_instructions.format(
+            current_date=get_current_date(),
+            research_topic=state.research_topic,
+        )
+
+        response = self._agent.run(prompt)
+        self._agent.clear_history()
+
+        logger.info("Planner raw output (truncated): %s", response[:500])
+
+        tasks_payload = self._extract_tasks(response)
+        todo_items: List[TodoItem] = []
+
+        for idx, item in enumerate(tasks_payload, start=1):
+            title = str(item.get("title") or f"任务{idx}").strip()
+            intent = str(item.get("intent") or "聚焦主题的关键问题").strip()
+            query = str(item.get("query") or state.research_topic).strip()
+
+            if not query:
+                query = state.research_topic
+
+            task = TodoItem(
+                id=idx,
+                title=title,
+                intent=intent,
+                query=query,
+            )
+            todo_items.append(task)
+
+        state.todo_items = todo_items
+
+        titles = [task.title for task in todo_items]
+        logger.info("Planner produced %d tasks: %s", len(todo_items), titles)
+        return todo_items
+
+    @staticmethod
+    def create_fallback_task(state: SummaryState) -> TodoItem:
+        """Create a minimal fallback task when planning failed."""
+
+        return TodoItem(
+            id=1,
+            title="基础背景梳理",
+            intent="收集主题的核心背景与最新动态",
+            query=f"{state.research_topic} 最新进展" if state.research_topic else "基础背景梳理",
+        )
+
+    # ------------------------------------------------------------------
+    # Parsing helpers
+    # ------------------------------------------------------------------
+    def _extract_tasks(self, raw_response: str) -> List[dict[str, Any]]:
+        """Parse planner output into a list of task dictionaries."""
+
+        text = raw_response.strip()
+        if self._config.strip_thinking_tokens:
+            text = strip_thinking_tokens(text)
+
+        json_payload = self._extract_json_payload(text)
+        tasks: List[dict[str, Any]] = []
+
+        if isinstance(json_payload, dict):
+            candidate = json_payload.get("tasks")
+            if isinstance(candidate, list):
+                for item in candidate:
+                    if isinstance(item, dict):
+                        tasks.append(item)
+        elif isinstance(json_payload, list):
+            for item in json_payload:
+                if isinstance(item, dict):
+                    tasks.append(item)
+
+        if not tasks:
+            tool_payload = self._extract_tool_payload(text)
+            if tool_payload and isinstance(tool_payload.get("tasks"), list):
+                for item in tool_payload["tasks"]:
+                    if isinstance(item, dict):
+                        tasks.append(item)
+
+        return tasks
+
+    def _extract_json_payload(self, text: str) -> Optional[dict[str, Any] | list]:
+        """Try to locate and parse a JSON object or array from the text."""
+
+        start = text.find("{")
+        end = text.rfind("}")
+        if start != -1 and end != -1 and end > start:
+            candidate = text[start : end + 1]
+            try:
+                return json.loads(candidate)
+            except json.JSONDecodeError:
+                pass
+
+        start = text.find("[")
+        end = text.rfind("]")
+        if start != -1 and end != -1 and end > start:
+            candidate = text[start : end + 1]
+            try:
+                return json.loads(candidate)
+            except json.JSONDecodeError:
+                return None
+
+        return None
+
+    def _extract_tool_payload(self, text: str) -> Optional[dict[str, Any]]:
+        """Parse the first TOOL_CALL expression in the output."""
+
+        match = TOOL_CALL_PATTERN.search(text)
+        if not match:
+            return None
+
+        body = match.group("body")
+
+        try:
+            payload = json.loads(body)
+            if isinstance(payload, dict):
+                return payload
+        except json.JSONDecodeError:
+            pass
+
+        parts = [segment.strip() for segment in body.split(",") if segment.strip()]
+        payload: dict[str, Any] = {}
+        for part in parts:
+            if "=" not in part:
+                continue
+            key, value = part.split("=", 1)
+            payload[key.strip()] = value.strip().strip('"').strip("'")
+
+        return payload or None

+ 77 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/reporter.py

@@ -0,0 +1,77 @@
+"""Service that consolidates task results into the final report."""
+
+from __future__ import annotations
+
+import json
+
+from hello_agents import ToolAwareSimpleAgent
+
+from models import SummaryState
+from config import Configuration
+from utils import strip_thinking_tokens
+from services.text_processing import strip_tool_calls
+
+
+class ReportingService:
+    """Generates the final structured report."""
+
+    def __init__(self, report_agent: ToolAwareSimpleAgent, config: Configuration) -> None:
+        self._agent = report_agent
+        self._config = config
+
+    def generate_report(self, state: SummaryState) -> str:
+        """Generate a structured report based on completed tasks."""
+
+        tasks_block = []
+        for task in state.todo_items:
+            summary_block = task.summary or "暂无可用信息"
+            sources_block = task.sources_summary or "暂无来源"
+            tasks_block.append(
+                f"### 任务 {task.id}: {task.title}\n"
+                f"- 任务目标:{task.intent}\n"
+                f"- 检索查询:{task.query}\n"
+                f"- 执行状态:{task.status}\n"
+                f"- 任务总结:\n{summary_block}\n"
+                f"- 来源概览:\n{sources_block}\n"
+            )
+
+        note_references = []
+        for task in state.todo_items:
+            if task.note_id:
+                note_references.append(
+                    f"- 任务 {task.id}《{task.title}》:note_id={task.note_id}"
+                )
+
+        notes_section = "\n".join(note_references) if note_references else "- 暂无可用任务笔记"
+
+        read_template = json.dumps({"action": "read", "note_id": "<note_id>"}, ensure_ascii=False)
+        create_conclusion_template = json.dumps(
+            {
+                "action": "create",
+                "title": f"研究报告:{state.research_topic}",
+                "note_type": "conclusion",
+                "tags": ["deep_research", "report"],
+                "content": "请在此沉淀最终报告要点",
+            },
+            ensure_ascii=False,
+        )
+
+        prompt = (
+            f"研究主题:{state.research_topic}\n"
+            f"任务概览:\n{''.join(tasks_block)}\n"
+            f"可用任务笔记:\n{notes_section}\n"
+            f"请针对每条任务笔记使用格式:[TOOL_CALL:note:{read_template}] 读取内容,整合所有信息后撰写报告。\n"
+            f"如需输出汇总结论,可追加调用:[TOOL_CALL:note:{create_conclusion_template}] 保存报告要点。"
+        )
+
+        response = self._agent.run(prompt)
+        self._agent.clear_history()
+
+        report_text = response.strip()
+        if self._config.strip_thinking_tokens:
+            report_text = strip_thinking_tokens(report_text)
+
+        report_text = strip_tool_calls(report_text).strip()
+
+        return report_text or "报告生成失败,请检查输入。"
+

+ 109 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/search.py

@@ -0,0 +1,109 @@
+"""Search dispatch helpers leveraging HelloAgents SearchTool."""
+
+from __future__ import annotations
+
+import logging
+import io
+from contextlib import redirect_stdout
+from typing import Any, Optional, Tuple
+
+from hello_agents.tools import SearchTool
+
+from config import Configuration
+from utils import (
+    deduplicate_and_format_sources,
+    format_sources,
+    get_config_value,
+)
+
+logger = logging.getLogger(__name__)
+
+MAX_TOKENS_PER_SOURCE = 2000
+_GLOBAL_SEARCH_TOOL: SearchTool | None = None
+
+
+def _get_search_tool() -> SearchTool:
+    global _GLOBAL_SEARCH_TOOL
+    if _GLOBAL_SEARCH_TOOL is None:
+        # hello_agents SearchTool prints status messages during backend setup.
+        # Suppress them so Windows GBK consoles do not fail on emoji output.
+        with redirect_stdout(io.StringIO()):
+            _GLOBAL_SEARCH_TOOL = SearchTool(backend="hybrid")
+    return _GLOBAL_SEARCH_TOOL
+
+
+def dispatch_search(
+    query: str,
+    config: Configuration,
+    loop_count: int,
+) -> Tuple[dict[str, Any] | None, list[str], Optional[str], str]:
+    """Execute configured search backend and normalise response payload."""
+
+    search_api = get_config_value(config.search_api)
+
+    try:
+        raw_response = _get_search_tool().run(
+            {
+                "input": query,
+                "backend": search_api,
+                "mode": "structured",
+                "fetch_full_page": config.fetch_full_page,
+                "max_results": 5,
+                "max_tokens_per_source": MAX_TOKENS_PER_SOURCE,
+                "loop_count": loop_count,
+            }
+        )
+    except Exception as exc:  # pragma: no cover - defensive logging
+        logger.exception("Search backend %s failed: %s", search_api, exc)
+        raise
+
+    if isinstance(raw_response, str):
+        notices = [raw_response]
+        logger.warning("Search backend %s returned text notice: %s", search_api, raw_response)
+        payload: dict[str, Any] = {
+            "results": [],
+            "backend": search_api,
+            "answer": None,
+            "notices": notices,
+        }
+    else:
+        payload = raw_response
+        notices = list(payload.get("notices") or [])
+
+    backend_label = str(payload.get("backend") or search_api)
+    answer_text = payload.get("answer")
+    results = payload.get("results", [])
+
+    if notices:
+        for notice in notices:
+            logger.info("Search notice (%s): %s", backend_label, notice)
+
+    logger.info(
+        "Search backend=%s resolved_backend=%s answer=%s results=%s",
+        search_api,
+        backend_label,
+        bool(answer_text),
+        len(results),
+    )
+
+    return payload, notices, answer_text, backend_label
+
+
+def prepare_research_context(
+    search_result: dict[str, Any] | None,
+    answer_text: Optional[str],
+    config: Configuration,
+) -> tuple[str, str]:
+    """Build structured context and source summary for downstream agents."""
+
+    sources_summary = format_sources(search_result)
+    context = deduplicate_and_format_sources(
+        search_result or {"results": []},
+        max_tokens_per_source=MAX_TOKENS_PER_SOURCE,
+        fetch_full_page=config.fetch_full_page,
+    )
+
+    if answer_text:
+        context = f"AI直接答案:\n{answer_text}\n\n{context}"
+
+    return sources_summary, context

+ 125 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/summarizer.py

@@ -0,0 +1,125 @@
+"""Task summarization utilities."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Iterator
+from typing import Tuple
+
+from hello_agents import ToolAwareSimpleAgent
+
+from models import SummaryState, TodoItem
+from config import Configuration
+from utils import strip_thinking_tokens
+from services.notes import build_note_guidance
+from services.text_processing import strip_tool_calls
+
+
+class SummarizationService:
+    """Handles synchronous and streaming task summarization."""
+
+    def __init__(
+        self,
+        summarizer_factory: Callable[[], ToolAwareSimpleAgent],
+        config: Configuration,
+    ) -> None:
+        self._agent_factory = summarizer_factory
+        self._config = config
+
+    def summarize_task(self, state: SummaryState, task: TodoItem, context: str) -> str:
+        """Generate a task-specific summary using the summarizer agent."""
+
+        prompt = self._build_prompt(state, task, context)
+
+        agent = self._agent_factory()
+        try:
+            response = agent.run(prompt)
+        finally:
+            agent.clear_history()
+
+        summary_text = response.strip()
+        if self._config.strip_thinking_tokens:
+            summary_text = strip_thinking_tokens(summary_text)
+
+        summary_text = strip_tool_calls(summary_text).strip()
+
+        return summary_text or "暂无可用信息"
+
+    def stream_task_summary(
+        self, state: SummaryState, task: TodoItem, context: str
+    ) -> Tuple[Iterator[str], Callable[[], str]]:
+        """Stream the summary text for a task while collecting full output."""
+
+        prompt = self._build_prompt(state, task, context)
+        remove_thinking = self._config.strip_thinking_tokens
+        raw_buffer = ""
+        visible_output = ""
+        emit_index = 0
+        agent = self._agent_factory()
+
+        def flush_visible() -> Iterator[str]:
+            nonlocal emit_index, raw_buffer
+            while True:
+                start = raw_buffer.find("<think>", emit_index)
+                if start == -1:
+                    if emit_index < len(raw_buffer):
+                        segment = raw_buffer[emit_index:]
+                        emit_index = len(raw_buffer)
+                        if segment:
+                            yield segment
+                    break
+
+                if start > emit_index:
+                    segment = raw_buffer[emit_index:start]
+                    emit_index = start
+                    if segment:
+                        yield segment
+
+                end = raw_buffer.find("</think>", start)
+                if end == -1:
+                    break
+                emit_index = end + len("</think>")
+
+        def generator() -> Iterator[str]:
+            nonlocal raw_buffer, visible_output, emit_index
+            try:
+                for chunk in agent.stream_run(prompt):
+                    raw_buffer += chunk
+                    if remove_thinking:
+                        for segment in flush_visible():
+                            visible_output += segment
+                            if segment:
+                                yield segment
+                    else:
+                        visible_output += chunk
+                        if chunk:
+                            yield chunk
+            finally:
+                if remove_thinking:
+                    for segment in flush_visible():
+                        visible_output += segment
+                        if segment:
+                            yield segment
+                agent.clear_history()
+
+        def get_summary() -> str:
+            if remove_thinking:
+                cleaned = strip_thinking_tokens(visible_output)
+            else:
+                cleaned = visible_output
+
+            return strip_tool_calls(cleaned).strip()
+
+        return generator(), get_summary
+
+    def _build_prompt(self, state: SummaryState, task: TodoItem, context: str) -> str:
+        """Construct the summarization prompt shared by both modes."""
+
+        return (
+            f"任务主题:{state.research_topic}\n"
+            f"任务名称:{task.title}\n"
+            f"任务目标:{task.intent}\n"
+            f"检索查询:{task.query}\n"
+            f"任务上下文:\n{context}\n"
+            f"{build_note_guidance(task)}\n"
+            "请按照以上协作要求先同步笔记,然后返回一份面向用户的 Markdown 总结(仍遵循任务总结模板)。"
+        )

+ 16 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/text_processing.py

@@ -0,0 +1,16 @@
+"""Utility helpers for normalizing agent generated text."""
+
+from __future__ import annotations
+
+import re
+
+
+def strip_tool_calls(text: str) -> str:
+    """移除文本中的工具调用标记。"""
+
+    if not text:
+        return text
+
+    pattern = re.compile(r"\[TOOL_CALL:[^\]]+\]")
+    return pattern.sub("", text)
+

+ 215 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/tool_events.py

@@ -0,0 +1,215 @@
+"""Utility for collecting and exposing tool call events."""
+
+from __future__ import annotations
+
+import logging
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from threading import Lock
+from typing import Any, Callable, Optional
+
+from models import SummaryState, TodoItem
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ToolCallEvent:
+    """Internal representation of a tool call event."""
+
+    id: int
+    agent: str
+    tool: str
+    raw_parameters: str
+    parsed_parameters: dict[str, Any]
+    result: str
+    task_id: Optional[int]
+    note_id: Optional[str]
+
+
+class ToolCallTracker:
+    """Collects tool call events and converts them to SSE payloads."""
+
+    def __init__(self, notes_workspace: Optional[str]) -> None:
+        self._notes_workspace = notes_workspace
+        self._events: list[ToolCallEvent] = []
+        self._cursor = 0
+        self._lock = Lock()
+        self._event_sink: Optional[Callable[[dict[str, Any]], None]] = None
+
+    def record(self, payload: dict[str, Any]) -> None:
+        """记录模型工具调用情况,便于日志与前端展示。"""
+
+        agent_name = str(payload.get("agent_name") or "unknown")
+        tool_name = str(payload.get("tool_name") or "unknown")
+        raw_parameters = str(payload.get("raw_parameters") or "")
+        parsed_parameters = payload.get("parsed_parameters") or {}
+        result_text = str(payload.get("result") or "")
+
+        if not isinstance(parsed_parameters, dict):
+            parsed_parameters = {}
+
+        task_id = self._infer_task_id(parsed_parameters)
+        note_id: Optional[str] = None
+
+        if tool_name == "note":
+            note_id = parsed_parameters.get("note_id")
+            if note_id is None:
+                note_id = self._extract_note_id(result_text)
+
+        event = ToolCallEvent(
+            id=len(self._events) + 1,
+            agent=agent_name,
+            tool=tool_name,
+            raw_parameters=raw_parameters,
+            parsed_parameters=parsed_parameters,
+            result=result_text,
+            task_id=task_id,
+            note_id=note_id,
+        )
+
+        with self._lock:
+            self._events.append(event)
+
+        logger.info(
+            "Tool call recorded: agent=%s tool=%s task_id=%s note_id=%s parsed_parameters=%s",
+            agent_name,
+            tool_name,
+            task_id,
+            note_id,
+            parsed_parameters,
+        )
+
+        sink = self._event_sink
+        if sink:
+            sink(self._build_payload(event, step=None))
+
+    # ------------------------------------------------------------------
+    # Draining helpers
+    # ------------------------------------------------------------------
+    def drain(self, state: SummaryState, *, step: Optional[int] = None) -> list[dict[str, Any]]:
+        """提取尚未消费的工具调用事件,并同步任务的 note_id。"""
+
+        with self._lock:
+            if self._cursor >= len(self._events):
+                return []
+            new_events = self._events[self._cursor :]
+            self._cursor = len(self._events)
+
+        if state.todo_items:
+            for event in new_events:
+                task_id = event.task_id
+                note_id = event.note_id
+                if task_id is None or not note_id:
+                    continue
+                self._attach_note_to_task(state.todo_items, task_id, note_id)
+
+        payloads: list[dict[str, Any]] = []
+        for event in new_events:
+            payload = self._build_payload(event, step=step)
+            payloads.append(payload)
+
+        return payloads
+
+    def reset(self) -> None:
+        """Clear recorded events."""
+
+        with self._lock:
+            self._events.clear()
+            self._cursor = 0
+
+    def as_dicts(self) -> list[dict[str, Any]]:
+        """Expose a snapshot of raw events for backwards compatibility."""
+
+        with self._lock:
+            return [
+                {
+                    "id": event.id,
+                    "agent": event.agent,
+                    "tool": event.tool,
+                    "raw_parameters": event.raw_parameters,
+                    "parsed_parameters": event.parsed_parameters,
+                    "result": event.result,
+                    "task_id": event.task_id,
+                    "note_id": event.note_id,
+                }
+                for event in self._events
+            ]
+
+    def set_event_sink(self, sink: Optional[Callable[[dict[str, Any]], None]]) -> None:
+        """Register a callback for immediate tool event notifications."""
+
+        self._event_sink = sink
+
+    def _build_payload(self, event: ToolCallEvent, step: Optional[int]) -> dict[str, Any]:
+        payload = {
+            "type": "tool_call",
+            "event_id": event.id,
+            "agent": event.agent,
+            "tool": event.tool,
+            "parameters": event.parsed_parameters,
+            "result": event.result,
+            "task_id": event.task_id,
+            "note_id": event.note_id,
+        }
+        if event.note_id and self._notes_workspace:
+            note_path = Path(self._notes_workspace) / f"{event.note_id}.md"
+            payload["note_path"] = str(note_path)
+        if step is not None:
+            payload["step"] = step
+        return payload
+
+    # ------------------------------------------------------------------
+    # Internal helpers
+    # ------------------------------------------------------------------
+    def _attach_note_to_task(self, tasks: list[TodoItem], task_id: int, note_id: str) -> None:
+        """Update matching TODO item with note metadata."""
+
+        for task in tasks:
+            if task.id != task_id:
+                continue
+
+            if task.note_id != note_id:
+                task.note_id = note_id
+                if self._notes_workspace:
+                    task.note_path = str(Path(self._notes_workspace) / f"{note_id}.md")
+            elif task.note_path is None and self._notes_workspace:
+                task.note_path = str(Path(self._notes_workspace) / f"{note_id}.md")
+            break
+
+    def _infer_task_id(self, parameters: dict[str, Any]) -> Optional[int]:
+        """尝试从工具参数推断 task_id。"""
+
+        if not parameters:
+            return None
+
+        if "task_id" in parameters:
+            try:
+                return int(parameters["task_id"])
+            except (TypeError, ValueError):
+                pass
+
+        tags = parameters.get("tags")
+        if isinstance(tags, list):
+            for tag in tags:
+                match = re.search(r"task_(\d+)", str(tag))
+                if match:
+                    return int(match.group(1))
+
+        title = parameters.get("title")
+        if isinstance(title, str):
+            match = re.search(r"任务\s*(\d+)", title)
+            if match:
+                return int(match.group(1))
+
+        return None
+
+    def _extract_note_id(self, response: str) -> Optional[str]:
+        if not response:
+            return None
+
+        match = re.search(r"ID:\s*([^\n]+)", response)
+        if match:
+            return match.group(1).strip()
+        return None

+ 84 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/utils.py

@@ -0,0 +1,84 @@
+"""Utility helpers shared across deep researcher services."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, List, Union
+
+CHARS_PER_TOKEN = 4
+
+logger = logging.getLogger(__name__)
+
+
+def get_config_value(value: Any) -> str:
+    """Return configuration value as plain string."""
+
+    return value if isinstance(value, str) else value.value
+
+
+def strip_thinking_tokens(text: str) -> str:
+    """Remove ``<think>`` sections from model responses."""
+
+    while "<think>" in text and "</think>" in text:
+        start = text.find("<think>")
+        end = text.find("</think>") + len("</think>")
+        text = text[:start] + text[end:]
+    return text
+
+
+def deduplicate_and_format_sources(
+    search_response: Dict[str, Any] | List[Dict[str, Any]],
+    max_tokens_per_source: int,
+    *,
+    fetch_full_page: bool = False,
+) -> str:
+    """Format and deduplicate search results for downstream prompting."""
+
+    if isinstance(search_response, dict):
+        sources_list = search_response.get("results", [])
+    else:
+        sources_list = search_response
+
+    unique_sources: dict[str, Dict[str, Any]] = {}
+    for source in sources_list:
+        url = source.get("url")
+        if not url:
+            continue
+        if url not in unique_sources:
+            unique_sources[url] = source
+
+    formatted_parts: List[str] = []
+    for source in unique_sources.values():
+        title = source.get("title") or source.get("url", "")
+        content = source.get("content", "")
+        formatted_parts.append(f"信息来源: {title}\n\n")
+        formatted_parts.append(f"URL: {source.get('url', '')}\n\n")
+        formatted_parts.append(f"信息内容: {content}\n\n")
+
+        if fetch_full_page:
+            raw_content = source.get("raw_content")
+            if raw_content is None:
+                logger.debug("raw_content missing for %s", source.get("url", ""))
+                raw_content = ""
+            char_limit = max_tokens_per_source * CHARS_PER_TOKEN
+            if len(raw_content) > char_limit:
+                raw_content = f"{raw_content[:char_limit]}... [truncated]"
+            formatted_parts.append(
+                f"详细信息内容限制为 {max_tokens_per_source} 个 token: {raw_content}\n\n"
+            )
+
+    return "".join(formatted_parts).strip()
+
+
+def format_sources(search_results: Dict[str, Any] | None) -> str:
+    """Return bullet list summarising search sources."""
+
+    if not search_results:
+        return ""
+
+    results = search_results.get("results", [])
+    return "\n".join(
+        f"* {item.get('title', item.get('url', ''))} : {item.get('url', '')}"
+        for item in results
+        if item.get("url")
+    )

+ 95 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/README.md

@@ -0,0 +1,95 @@
+# RSS Digest
+
+一个最小可用的日更阅读简报工具:
+
+- 拉取 RSS/Atom 订阅源
+- 抓取文章正文
+- 调用 SiliconFlow 兼容 OpenAI 的 API 生成中文摘要
+- 可选生成中文全译
+- 输出每日 HTML 简报,适合每天点开看一眼
+
+## 目录结构
+
+```text
+rss_digest/
+├─ config/
+│  ├─ sources.json
+│  └─ sources_full.opml
+├─ data/
+│  ├─ raw/
+│  ├─ extracted/
+│  ├─ translated/
+│  └─ digests/
+├─ scripts/
+│  └─ run_daily.ps1
+├─ src/
+│  └─ rss_digest/
+│     ├─ __init__.py
+│     ├─ config.py
+│     ├─ db.py
+│     ├─ digest.py
+│     ├─ extractor.py
+│     ├─ feeds.py
+│     ├─ llm.py
+│     └─ pipeline.py
+├─ state/
+├─ .env
+├─ .env.example
+└─ main.py
+```
+
+## 环境变量
+
+在 `rss_digest/.env` 里配置:
+
+```env
+LLM_MODEL_ID=Qwen/Qwen3-235B-A22B-Instruct-2507
+LLM_API_KEY=sk-xxxxx
+LLM_BASE_URL=https://api.siliconflow.cn/v1
+DISABLE_SYSTEM_PROXY=true
+# PROXY_URL=http://127.0.0.1:7890
+FETCH_FULL_TRANSLATION=false
+MAX_ARTICLES_PER_RUN=12
+REQUEST_TIMEOUT_SECONDS=30
+```
+
+说明:
+- 当前只读取 `LLM_*` 变量名。
+- 默认会清掉继承到进程里的系统代理,避免被无效代理拦住。
+- 如果你确实需要代理,在 `.env` 里设置 `PROXY_URL` 即可。
+- 默认只做中文摘要,不做全文翻译。
+- 如果把 `FETCH_FULL_TRANSLATION=true`,会额外为文章生成中文全译,成本更高。
+
+## 运行方式
+
+在 `D:\SoftWare\pycharm\Project\regularTest` 下执行:
+
+```powershell
+.venv\Scripts\python.exe rss_digest\main.py
+```
+
+或直接运行:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\rss_digest\scripts\run_daily.ps1
+```
+
+## 输出结果
+
+- 状态文件:`rss_digest\state\articles.json`
+- 日报 HTML:`rss_digest\data\digests\digest_YYYY-MM-DD.html`
+
+## 目前实现范围
+
+- 已支持 RSS/Atom 的基础拉取
+- 已支持正文抓取和基础文本清洗
+- 已支持中文摘要生成
+- 已支持 HTML 简报
+
+## 后续建议
+
+下一步如果你要把质量做稳,优先补这三项:
+
+1. 接入 `trafilatura` 做正文抽取
+2. 给摘要增加分类标签和“建议细读/可跳过”
+3. 增加 Windows 计划任务,真正每天自动跑

+ 148 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/config/sources.json

@@ -0,0 +1,148 @@
+{
+  "sources": [
+    {
+      "name": "Andrej Karpathy",
+      "category": "AI/LLM Practice",
+      "site_url": "https://karpathy.bearblog.dev",
+      "feed_url": "https://karpathy.bearblog.dev/feed/"
+    },
+    {
+      "name": "Simon Willison",
+      "category": "AI/LLM Practice",
+      "site_url": "https://simonwillison.net",
+      "feed_url": "https://simonwillison.net/atom/everything/"
+    },
+    {
+      "name": "minimaxir",
+      "category": "AI/LLM Practice",
+      "site_url": "https://minimaxir.com",
+      "feed_url": "https://minimaxir.com/index.xml"
+    },
+    {
+      "name": "Gwern",
+      "category": "AI/LLM Practice",
+      "site_url": "https://gwern.net",
+      "feed_url": "https://gwern.substack.com/feed"
+    },
+    {
+      "name": "Gary Marcus",
+      "category": "AI/LLM Critique",
+      "site_url": "https://garymarcus.substack.com",
+      "feed_url": "https://garymarcus.substack.com/feed"
+    },
+    {
+      "name": "Ethan Mollick",
+      "category": "AI/LLM Practice",
+      "site_url": "https://www.oneusefulthing.org",
+      "feed_url": "https://www.oneusefulthing.org/feed"
+    },
+    {
+      "name": "Latent.Space",
+      "category": "AI/LLM Practice",
+      "site_url": "https://www.latent.space",
+      "feed_url": "https://www.latent.space/feed"
+    },
+    {
+      "name": "Chip Huyen",
+      "category": "AI/LLM Practice",
+      "site_url": "https://huyenchip.com",
+      "feed_url": "https://huyenchip.com/feed.xml"
+    },
+    {
+      "name": "Sebastian Raschka",
+      "category": "AI/LLM Practice",
+      "site_url": "https://sebastianraschka.com",
+      "feed_url": "https://sebastianraschka.com/rss_feed.xml"
+    },
+    {
+      "name": "Eugene Yan",
+      "category": "AI/LLM Practice",
+      "site_url": "https://eugeneyan.com",
+      "feed_url": "https://eugeneyan.com/feed.xml"
+    },
+    {
+      "name": "geohot",
+      "category": "AI/Engineering",
+      "site_url": "https://geohot.github.io",
+      "feed_url": "https://geohot.github.io/blog/feed.xml"
+    },
+    {
+      "name": "Paul Graham",
+      "category": "Business and Long-term Judgment",
+      "site_url": "https://paulgraham.com",
+      "feed_url": "http://www.aaronsw.com/2002/feeds/pgessays.rss"
+    },
+    {
+      "name": "Dwarkesh Patel",
+      "category": "Business and Long-term Judgment",
+      "site_url": "https://www.dwarkeshpatel.com",
+      "feed_url": "https://www.dwarkeshpatel.com/feed"
+    },
+    {
+      "name": "Where's Your Ed At",
+      "category": "Business and Long-term Judgment",
+      "site_url": "https://www.wheresyoured.at",
+      "feed_url": "https://www.wheresyoured.at/rss/"
+    },
+    {
+      "name": "Joan Westenberg",
+      "category": "AI/Industry Commentary",
+      "site_url": "https://joanwestenberg.com",
+      "feed_url": "https://joanwestenberg.com/rss"
+    },
+    {
+      "name": "Geoffrey Litt",
+      "category": "AI-adjacent Product Thinking",
+      "site_url": "https://geoffreylitt.com",
+      "feed_url": "https://www.geoffreylitt.com/feed.xml"
+    },
+    {
+      "name": "Derek Thompson",
+      "category": "AI-adjacent Industry Trends",
+      "site_url": "https://derekthompson.org",
+      "feed_url": "https://www.theatlantic.com/feed/author/derek-thompson/"
+    },
+    {
+      "name": "Ben Evans",
+      "category": "Business and Long-term Judgment",
+      "site_url": "https://www.ben-evans.com",
+      "feed_url": "https://www.ben-evans.com/benedictevans?format=rss"
+    },
+    {
+      "name": "Stratechery",
+      "category": "Business and Long-term Judgment",
+      "site_url": "https://stratechery.com",
+      "feed_url": "https://stratechery.com/feed/"
+    },
+    {
+      "name": "Asterisk Mag",
+      "category": "Business and Long-term Judgment",
+      "site_url": "https://asteriskmag.com",
+      "feed_url": "https://asteriskmag.com/feed"
+    },
+    {
+      "name": "Steve Blank",
+      "category": "AI-adjacent Business Thinking",
+      "site_url": "https://steveblank.com",
+      "feed_url": "https://steveblank.com/feed/"
+    },
+    {
+      "name": "Construction Physics",
+      "category": "AI-adjacent Industry Trends",
+      "site_url": "https://construction-physics.com",
+      "feed_url": "https://www.construction-physics.com/feed"
+    },
+    {
+      "name": "Experimental History",
+      "category": "AI-adjacent Science and Society",
+      "site_url": "https://experimental-history.com",
+      "feed_url": "https://www.experimental-history.com/feed"
+    },
+    {
+      "name": "Anil Dash",
+      "category": "AI-adjacent Technology Culture",
+      "site_url": "https://anildash.com",
+      "feed_url": "https://anildash.com/feed.xml"
+    }
+  ]
+}

+ 38 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/config/sources_full.opml

@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<opml version="2.0">
+  <head>
+    <title>RSS Digest Full Sources</title>
+  </head>
+  <body>
+    <outline text="RSS Digest Full Sources" title="RSS Digest Full Sources">
+      <outline text="AI/LLM Practice" title="AI/LLM Practice">
+        <outline type="rss" text="Andrej Karpathy" title="Andrej Karpathy" xmlUrl="https://karpathy.bearblog.dev/feed/" htmlUrl="https://karpathy.bearblog.dev"/>
+        <outline type="rss" text="Simon Willison" title="Simon Willison" xmlUrl="https://simonwillison.net/atom/everything/" htmlUrl="https://simonwillison.net"/>
+        <outline type="rss" text="minimaxir" title="minimaxir" xmlUrl="https://minimaxir.com/index.xml" htmlUrl="https://minimaxir.com"/>
+        <outline type="rss" text="Gwern" title="Gwern" xmlUrl="https://gwern.substack.com/feed" htmlUrl="https://gwern.net"/>
+        <outline type="rss" text="Gary Marcus" title="Gary Marcus" xmlUrl="https://garymarcus.substack.com/feed" htmlUrl="https://garymarcus.substack.com"/>
+        <outline type="rss" text="Ethan Mollick" title="Ethan Mollick" xmlUrl="https://www.oneusefulthing.org/feed" htmlUrl="https://www.oneusefulthing.org"/>
+        <outline type="rss" text="Latent.Space" title="Latent.Space" xmlUrl="https://www.latent.space/feed" htmlUrl="https://www.latent.space"/>
+        <outline type="rss" text="Chip Huyen" title="Chip Huyen" xmlUrl="https://huyenchip.com/feed.xml" htmlUrl="https://huyenchip.com"/>
+        <outline type="rss" text="Sebastian Raschka" title="Sebastian Raschka" xmlUrl="https://sebastianraschka.com/rss_feed.xml" htmlUrl="https://sebastianraschka.com"/>
+        <outline type="rss" text="Eugene Yan" title="Eugene Yan" xmlUrl="https://eugeneyan.com/feed.xml" htmlUrl="https://eugeneyan.com"/>
+        <outline type="rss" text="geohot" title="geohot" xmlUrl="https://geohot.github.io/blog/feed.xml" htmlUrl="https://geohot.github.io"/>
+      </outline>
+      <outline text="Business and Long-term Judgment" title="Business and Long-term Judgment">
+        <outline type="rss" text="Paul Graham" title="Paul Graham" xmlUrl="http://www.aaronsw.com/2002/feeds/pgessays.rss" htmlUrl="https://paulgraham.com"/>
+        <outline type="rss" text="Dwarkesh Patel" title="Dwarkesh Patel" xmlUrl="https://www.dwarkeshpatel.com/feed" htmlUrl="https://www.dwarkeshpatel.com"/>
+        <outline type="rss" text="Where's Your Ed At" title="Where's Your Ed At" xmlUrl="https://www.wheresyoured.at/rss/" htmlUrl="https://www.wheresyoured.at"/>
+        <outline type="rss" text="Joan Westenberg" title="Joan Westenberg" xmlUrl="https://joanwestenberg.com/rss" htmlUrl="https://joanwestenberg.com"/>
+        <outline type="rss" text="Geoffrey Litt" title="Geoffrey Litt" xmlUrl="https://www.geoffreylitt.com/feed.xml" htmlUrl="https://geoffreylitt.com"/>
+        <outline type="rss" text="Derek Thompson" title="Derek Thompson" xmlUrl="https://www.theatlantic.com/feed/author/derek-thompson/" htmlUrl="https://derekthompson.org"/>
+        <outline type="rss" text="Ben Evans" title="Ben Evans" xmlUrl="https://www.ben-evans.com/benedictevans?format=rss" htmlUrl="https://www.ben-evans.com"/>
+        <outline type="rss" text="Stratechery" title="Stratechery" xmlUrl="https://stratechery.com/feed/" htmlUrl="https://stratechery.com"/>
+        <outline type="rss" text="Asterisk Mag" title="Asterisk Mag" xmlUrl="https://asteriskmag.com/feed" htmlUrl="https://asteriskmag.com"/>
+        <outline type="rss" text="Steve Blank" title="Steve Blank" xmlUrl="https://steveblank.com/feed/" htmlUrl="https://steveblank.com"/>
+        <outline type="rss" text="Construction Physics" title="Construction Physics" xmlUrl="https://www.construction-physics.com/feed" htmlUrl="https://construction-physics.com"/>
+        <outline type="rss" text="Experimental History" title="Experimental History" xmlUrl="https://www.experimental-history.com/feed" htmlUrl="https://experimental-history.com"/>
+        <outline type="rss" text="Anil Dash" title="Anil Dash" xmlUrl="https://anildash.com/feed.xml" htmlUrl="https://anildash.com"/>
+      </outline>
+    </outline>
+  </body>
+</opml>

+ 20 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/main.py

@@ -0,0 +1,20 @@
+from pathlib import Path
+import sys
+
+
+ROOT = Path(__file__).resolve().parent
+SRC = ROOT / "src"
+DATA_ROOT = ROOT.parents[1] / "data" / "rss_digest"
+
+if str(SRC) not in sys.path:
+    sys.path.insert(0, str(SRC))
+
+from rss_digest.pipeline import run_pipeline
+from rss_digest.ui_server import serve_ui
+
+
+if __name__ == "__main__":
+    if len(sys.argv) > 1 and sys.argv[1] == "run":
+        run_pipeline(ROOT, DATA_ROOT)
+    else:
+        serve_ui(ROOT, DATA_ROOT)

+ 11 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/scripts/run_daily.ps1

@@ -0,0 +1,11 @@
+$ErrorActionPreference = "Stop"
+
+$root = Split-Path -Parent $PSScriptRoot
+$python = Join-Path $root "..\.venv\Scripts\python.exe"
+$main = Join-Path $root "main.py"
+
+if (-not (Test-Path -LiteralPath $python)) {
+    throw "Python virtual environment not found: $python"
+}
+
+& $python $main

+ 1 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/__init__.py

@@ -0,0 +1 @@
+__all__ = []

+ 145 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/config.py

@@ -0,0 +1,145 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+import json
+import os
+
+
+@dataclass(slots=True)
+class AppConfig:
+    root_dir: Path
+    sources_file: Path
+    raw_dir: Path
+    extracted_dir: Path
+    translated_dir: Path
+    digests_dir: Path
+    state_dir: Path
+    db_path: Path
+    model_name: str
+    translation_model_name: str
+    api_key: str
+    base_url: str
+    fetch_full_translation: bool
+    max_articles_per_run: int
+    rss_fetch_concurrency: int
+    rss_source_limit: int
+    rss_entries_per_source: int
+    rss_ai_batch_size: int
+    rss_ai_max_concurrency: int
+    rss_relevance_threshold: int
+    rss_max_summary_articles_per_run: int
+    rss_max_digest_articles: int
+    request_timeout_seconds: int
+    llm_timeout_seconds: int
+    resummarize_existing: bool
+
+
+def load_env_file(env_path: Path) -> None:
+    if not env_path.exists():
+        return
+
+    loaded_values: dict[str, str] = {}
+    for raw_line in env_path.read_text(encoding="utf-8").splitlines():
+        line = raw_line.strip()
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+        key, value = line.split("=", 1)
+        loaded_values[key.strip()] = value.strip()
+
+    for key, value in loaded_values.items():
+        os.environ[key] = value
+
+    _apply_proxy_env(loaded_values)
+
+
+def _apply_proxy_env(loaded_values: dict[str, str]) -> None:
+    proxy_keys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"]
+    proxy_url = loaded_values.get("PROXY_URL", "").strip()
+    disable_system_proxy = loaded_values.get("DISABLE_SYSTEM_PROXY", "true").strip().lower() == "true"
+
+    if proxy_url:
+        for key in proxy_keys:
+            os.environ[key] = proxy_url
+        return
+
+    explicit_proxy_in_env = any(loaded_values.get(key, "").strip() for key in proxy_keys)
+    if explicit_proxy_in_env:
+        return
+
+    if disable_system_proxy:
+        for key in proxy_keys:
+            os.environ.pop(key, None)
+
+
+def ensure_dirs(paths: list[Path]) -> None:
+    for path in paths:
+        path.mkdir(parents=True, exist_ok=True)
+
+
+def build_config(root_dir: Path, data_root: Path | None = None) -> AppConfig:
+    env_path = root_dir / ".env"
+    load_env_file(env_path)
+
+    if data_root is None:
+        data_root = root_dir / "data"
+
+    runs_dir = data_root / "runs"
+    raw_dir = runs_dir / "raw"
+    extracted_dir = runs_dir / "extracted"
+    translated_dir = runs_dir / "translated"
+    digests_dir = runs_dir / "digests"
+    state_dir = data_root / "state"
+
+    ensure_dirs([raw_dir, extracted_dir, translated_dir, digests_dir, state_dir])
+
+    model_name = os.getenv("LLM_MODEL_ID", "").strip()
+    translation_model_name = os.getenv("TRANSLATION_MODEL_ID", "").strip()
+    api_key = os.getenv("LLM_API_KEY", "").strip()
+    base_url = os.getenv("LLM_BASE_URL", "https://api.siliconflow.cn/v1").strip().rstrip("/")
+    fetch_full_translation = os.getenv("FETCH_FULL_TRANSLATION", "false").strip().lower() == "true"
+    max_articles_per_run = int(os.getenv("RSS_MAX_NEW_ARTICLES_PER_RUN", os.getenv("MAX_ARTICLES_PER_RUN", "50")))
+    request_timeout_seconds = int(os.getenv("RSS_FETCH_TIMEOUT_SECONDS", os.getenv("REQUEST_TIMEOUT_SECONDS", "15")))
+    llm_timeout_seconds = int(os.getenv("LLM_TIMEOUT", "120"))
+    rss_fetch_concurrency = int(os.getenv("RSS_FETCH_CONCURRENCY", "10"))
+    rss_source_limit = int(os.getenv("RSS_SOURCE_LIMIT", "10"))
+    rss_entries_per_source = int(os.getenv("RSS_ENTRIES_PER_SOURCE", "5"))
+    rss_ai_batch_size = int(os.getenv("RSS_AI_BATCH_SIZE", "10"))
+    rss_ai_max_concurrency = int(os.getenv("RSS_AI_MAX_CONCURRENCY", "2"))
+    rss_relevance_threshold = int(os.getenv("RSS_RELEVANCE_THRESHOLD", "65"))
+    rss_max_summary_articles_per_run = int(os.getenv("RSS_MAX_SUMMARY_ARTICLES_PER_RUN", "20"))
+    rss_max_digest_articles = int(os.getenv("RSS_MAX_DIGEST_ARTICLES", "12"))
+    resummarize_existing = os.getenv("RESUMMARIZE_EXISTING", "false").strip().lower() == "true"
+
+    return AppConfig(
+        root_dir=root_dir,
+        sources_file=root_dir / "config" / "sources.json",
+        raw_dir=raw_dir,
+        extracted_dir=extracted_dir,
+        translated_dir=translated_dir,
+        digests_dir=digests_dir,
+        state_dir=state_dir,
+        db_path=state_dir / "articles.json",
+        model_name=model_name,
+        translation_model_name=translation_model_name,
+        api_key=api_key,
+        base_url=base_url,
+        fetch_full_translation=fetch_full_translation,
+        max_articles_per_run=max_articles_per_run,
+        rss_fetch_concurrency=max(1, rss_fetch_concurrency),
+        rss_source_limit=max(1, rss_source_limit),
+        rss_entries_per_source=max(1, rss_entries_per_source),
+        rss_ai_batch_size=max(1, rss_ai_batch_size),
+        rss_ai_max_concurrency=max(1, rss_ai_max_concurrency),
+        rss_relevance_threshold=max(0, min(100, rss_relevance_threshold)),
+        rss_max_summary_articles_per_run=max(1, rss_max_summary_articles_per_run),
+        rss_max_digest_articles=max(1, rss_max_digest_articles),
+        request_timeout_seconds=request_timeout_seconds,
+        llm_timeout_seconds=max(1, llm_timeout_seconds),
+        resummarize_existing=resummarize_existing,
+    )
+
+
+def load_sources(sources_file: Path) -> list[dict[str, str]]:
+    payload = json.loads(sources_file.read_text(encoding="utf-8"))
+    return payload.get("sources", [])

+ 259 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/db.py

@@ -0,0 +1,259 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+import json
+
+
+@dataclass
+class JsonDB:
+    db_path: Path
+    payload: dict[str, Any]
+
+    def save(self) -> None:
+        self.db_path.parent.mkdir(parents=True, exist_ok=True)
+        self.db_path.write_text(
+            json.dumps(self.payload, ensure_ascii=False, indent=2),
+            encoding="utf-8",
+        )
+
+
+def _default_article() -> dict[str, Any]:
+    return {
+        "id": 0,
+        "source_name": "",
+        "category": "",
+        "title": "",
+        "link": "",
+        "published_at": "",
+        "feed_summary": "",
+        "raw_html_path": None,
+        "extracted_text_path": None,
+        "translated_text_path": None,
+        "summary_cn": None,
+        "translation_cn": None,
+        "article_type": None,
+        "article_score": None,
+        "worth_reading": None,
+        "one_line": None,
+        "summary_data": None,
+        "status": "discovered",
+    }
+
+
+def _default_payload() -> dict[str, Any]:
+    return {
+        "next_id": 1,
+        "articles": [],
+        "digest_history": {},
+    }
+
+
+def connect(db_path: Path) -> JsonDB:
+    if db_path.exists():
+        try:
+            payload = json.loads(db_path.read_text(encoding="utf-8"))
+        except json.JSONDecodeError:
+            payload = _default_payload()
+    else:
+        payload = _default_payload()
+    return JsonDB(db_path=db_path, payload=payload)
+
+
+def init_db(conn: JsonDB) -> None:
+    conn.payload.setdefault("next_id", 1)
+    conn.payload.setdefault("articles", [])
+    conn.payload.setdefault("digest_history", {})
+
+    changed = False
+    for article in conn.payload["articles"]:
+        defaults = _default_article()
+        for key, value in defaults.items():
+            if key not in article:
+                article[key] = value
+                changed = True
+
+    if changed or not conn.db_path.exists():
+        conn.save()
+
+
+def _find_article(conn: JsonDB, article_id: int) -> dict[str, Any] | None:
+    for article in conn.payload["articles"]:
+        if article["id"] == article_id:
+            return article
+    return None
+
+
+def upsert_article(
+    conn: JsonDB,
+    *,
+    source_name: str,
+    category: str,
+    title: str,
+    link: str,
+    published_at: str,
+    feed_summary: str,
+) -> bool:
+    for article in conn.payload["articles"]:
+        if article["link"] == link:
+            return False
+
+    article = _default_article()
+    article.update(
+        {
+            "id": conn.payload["next_id"],
+            "source_name": source_name,
+            "category": category,
+            "title": title,
+            "link": link,
+            "published_at": published_at,
+            "feed_summary": feed_summary,
+            "status": "discovered",
+        }
+    )
+    conn.payload["articles"].append(article)
+    conn.payload["next_id"] += 1
+    conn.save()
+    return True
+
+
+def get_articles_by_status(conn: JsonDB, status: str, limit: int) -> list[dict[str, Any]]:
+    rows = [article for article in conn.payload["articles"] if article["status"] == status]
+    rows.sort(key=lambda item: (item.get("published_at") or "", item["id"]), reverse=True)
+    return rows[:limit]
+
+
+def update_article_paths(
+    conn: JsonDB,
+    article_id: int,
+    *,
+    raw_html_path: str | None = None,
+    extracted_text_path: str | None = None,
+    translated_text_path: str | None = None,
+    status: str | None = None,
+) -> None:
+    article = _find_article(conn, article_id)
+    if article is None:
+        return
+
+    if raw_html_path is not None:
+        article["raw_html_path"] = raw_html_path
+    if extracted_text_path is not None:
+        article["extracted_text_path"] = extracted_text_path
+    if translated_text_path is not None:
+        article["translated_text_path"] = translated_text_path
+    if status is not None:
+        article["status"] = status
+    conn.save()
+
+
+def update_article_texts(
+    conn: JsonDB,
+    article_id: int,
+    *,
+    summary_cn: str | None = None,
+    translation_cn: str | None = None,
+    translated_text_path: str | None = None,
+    article_type: str | None = None,
+    article_score: int | None = None,
+    worth_reading: str | None = None,
+    one_line: str | None = None,
+    summary_data: dict[str, Any] | None = None,
+    status: str | None = None,
+) -> None:
+    article = _find_article(conn, article_id)
+    if article is None:
+        return
+
+    if summary_cn is not None:
+        article["summary_cn"] = summary_cn
+    if translation_cn is not None:
+        article["translation_cn"] = translation_cn
+    if translated_text_path is not None:
+        article["translated_text_path"] = translated_text_path
+    if article_type is not None:
+        article["article_type"] = article_type
+    if article_score is not None:
+        article["article_score"] = article_score
+    if worth_reading is not None:
+        article["worth_reading"] = worth_reading
+    if one_line is not None:
+        article["one_line"] = one_line
+    if summary_data is not None:
+        article["summary_data"] = summary_data
+    if status is not None:
+        article["status"] = status
+    conn.save()
+
+
+def get_recent_articles(conn: JsonDB, limit: int = 30) -> list[dict[str, Any]]:
+    rows = [article for article in conn.payload["articles"] if article.get("summary_cn")]
+    rows.sort(
+        key=lambda item: (
+            item.get("published_at") or "",
+            item["id"],
+            item.get("article_score") or 0,
+        ),
+        reverse=True,
+    )
+    return rows[:limit]
+
+
+def get_undelivered_ready_articles(
+    conn: JsonDB,
+    digest_key: str,
+    limit: int = 30,
+    *,
+    exclude_ids: set[int] | None = None,
+) -> list[dict[str, Any]]:
+    delivered_ids = set(conn.payload.get("digest_history", {}).get(digest_key, []))
+    if exclude_ids:
+        delivered_ids |= exclude_ids
+
+    rows = [
+        article
+        for article in conn.payload["articles"]
+        if article.get("summary_cn") and article.get("id") not in delivered_ids
+    ]
+    rows.sort(
+        key=lambda item: (
+            item.get("published_at") or "",
+            item["id"],
+            item.get("article_score") or 0,
+        ),
+        reverse=True,
+    )
+    return rows[:limit]
+
+
+def mark_digest_delivered(conn: JsonDB, digest_key: str, article_ids: list[int]) -> None:
+    if not article_ids:
+        return
+
+    history = conn.payload.setdefault("digest_history", {})
+    delivered = set(history.get(digest_key, []))
+    delivered.update(int(article_id) for article_id in article_ids)
+    history[digest_key] = sorted(delivered)
+    conn.save()
+
+
+def bootstrap_existing_digest_delivery(
+    conn: JsonDB,
+    digest_key: str,
+    *,
+    exclude_ids: set[int] | None = None,
+) -> int:
+    history = conn.payload.setdefault("digest_history", {})
+    if digest_key in history:
+        return 0
+
+    exclude_ids = exclude_ids or set()
+    article_ids = [
+        int(article["id"])
+        for article in conn.payload["articles"]
+        if article.get("summary_cn") and article.get("id") not in exclude_ids
+    ]
+    history[digest_key] = sorted(set(article_ids))
+    conn.save()
+    return len(article_ids)

+ 309 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/digest.py

@@ -0,0 +1,309 @@
+from __future__ import annotations
+
+from datetime import datetime
+from pathlib import Path
+from html import escape
+
+
+def _render_list(items: list[str]) -> str:
+    if not items:
+        return "<p class='muted'>暂无</p>"
+    lis = "".join(f"<li>{escape(item)}</li>" for item in items)
+    return f"<ul>{lis}</ul>"
+
+
+def render_html(articles: list[dict[str, str]], output_path: Path) -> None:
+    generated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+    cards = []
+
+    for article in articles:
+        summary_data = article.get("summary_data") or {}
+        score = article.get("article_score") or summary_data.get("score") or 0
+        worth = article.get("worth_reading") or summary_data.get("worth_reading") or "未评级"
+        one_line = article.get("one_line") or summary_data.get("one_line") or "暂无一句话结论"
+        article_type = article.get("article_type") or summary_data.get("article_type") or "未分类"
+        summary = summary_data.get("summary") or article.get("summary_cn") or "暂无中文摘要"
+        key_points = summary_data.get("key_points") or []
+        keywords = summary_data.get("keywords") or []
+        why_it_matters = summary_data.get("why_it_matters") or ""
+        engineering_takeaway = summary_data.get("engineering_takeaway") or ""
+        business_signal = summary_data.get("business_signal") or ""
+        limitations = summary_data.get("limitations") or ""
+        recommended_action = summary_data.get("recommended_action") or ""
+
+        keyword_html = "".join(f"<span class='chip'>{escape(keyword)}</span>" for keyword in keywords)
+        score_class = "high" if score >= 85 else "mid" if score >= 70 else "low"
+
+        cards.append(
+            f"""
+            <section class="card">
+              <div class="meta">
+                <span class="tag">{escape(article.get("category", ""))}</span>
+                <span class="type">{escape(article_type)}</span>
+                <span class="source">{escape(article.get("source_name", ""))}</span>
+                <span class="date">{escape(article.get("published_at", "")[:10])}</span>
+              </div>
+              <div class="headline">
+                <h2><a href="{escape(article.get("link", ""))}" target="_blank" rel="noreferrer">{escape(article.get("title", ""))}</a></h2>
+                <div class="score {score_class}">
+                  <strong>{escape(str(score))}</strong>
+                  <span>{escape(worth)}</span>
+                </div>
+              </div>
+              <p class="one-line">{escape(one_line)}</p>
+              <div class="summary-block">
+                <h3>摘要</h3>
+                <p>{escape(summary)}</p>
+              </div>
+              <div class="grid">
+                <div class="panel">
+                  <h3>关键点</h3>
+                  {_render_list(key_points)}
+                </div>
+                <div class="panel">
+                  <h3>为什么值得关注</h3>
+                  <p>{escape(why_it_matters or '暂无')}</p>
+                </div>
+                <div class="panel">
+                  <h3>工程 / 决策启发</h3>
+                  <p>{escape(engineering_takeaway or '暂无')}</p>
+                </div>
+                <div class="panel">
+                  <h3>商业信号</h3>
+                  <p>{escape(business_signal or '暂无')}</p>
+                </div>
+                <div class="panel">
+                  <h3>局限与边界</h3>
+                  <p>{escape(limitations or '暂无')}</p>
+                </div>
+                <div class="panel">
+                  <h3>下一步动作</h3>
+                  <p>{escape(recommended_action or '暂无')}</p>
+                </div>
+              </div>
+              <div class="keywords">{keyword_html or "<span class='muted'>暂无关键词</span>"}</div>
+            </section>
+            """
+        )
+
+    html_doc = f"""<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>AI RSS Daily Digest</title>
+  <style>
+    :root {{
+      --bg: #f6f1e7;
+      --panel: rgba(255, 252, 246, 0.92);
+      --ink: #1d1a16;
+      --muted: #6e6458;
+      --line: #dbd0be;
+      --accent: #9a4f2b;
+      --accent-soft: #f5e0cf;
+      --green: #2f6b4f;
+      --green-soft: #dff1e7;
+      --amber: #8a5d17;
+      --amber-soft: #f6e7c5;
+      --rose: #8a3e3a;
+      --rose-soft: #f5dcd9;
+    }}
+    * {{ box-sizing: border-box; }}
+    body {{
+      margin: 0;
+      font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
+      color: var(--ink);
+      background:
+        radial-gradient(circle at top right, #f7e6d9 0, transparent 26%),
+        radial-gradient(circle at left bottom, #ece1cf 0, transparent 22%),
+        linear-gradient(180deg, #f7f2e8 0%, #efe7da 100%);
+    }}
+    .page {{
+      max-width: 1180px;
+      margin: 0 auto;
+      padding: 28px 18px 56px;
+    }}
+    .hero {{
+      padding: 24px 26px;
+      border-radius: 22px;
+      border: 1px solid var(--line);
+      background: var(--panel);
+      backdrop-filter: blur(6px);
+      box-shadow: 0 14px 28px rgba(0, 0, 0, 0.04);
+    }}
+    .hero h1 {{
+      margin: 0 0 10px;
+      font-size: 34px;
+      line-height: 1.08;
+    }}
+    .hero p {{
+      margin: 0;
+      color: var(--muted);
+      line-height: 1.7;
+    }}
+    .list {{
+      margin-top: 22px;
+      display: grid;
+      gap: 18px;
+    }}
+    .card {{
+      background: var(--panel);
+      border: 1px solid var(--line);
+      border-radius: 20px;
+      padding: 22px;
+      box-shadow: 0 12px 26px rgba(0, 0, 0, 0.03);
+    }}
+    .meta {{
+      display: flex;
+      gap: 10px;
+      flex-wrap: wrap;
+      align-items: center;
+      color: var(--muted);
+      font-size: 13px;
+      margin-bottom: 12px;
+    }}
+    .tag, .type, .chip {{
+      display: inline-flex;
+      align-items: center;
+      border-radius: 999px;
+      padding: 4px 10px;
+      font-size: 12px;
+      font-weight: 600;
+    }}
+    .tag {{
+      background: var(--accent-soft);
+      color: var(--accent);
+    }}
+    .type {{
+      background: #ece6ff;
+      color: #5744a3;
+    }}
+    .headline {{
+      display: flex;
+      justify-content: space-between;
+      gap: 16px;
+      align-items: flex-start;
+    }}
+    h2 {{
+      margin: 0;
+      font-size: 24px;
+      line-height: 1.28;
+      max-width: 82%;
+    }}
+    a {{
+      color: inherit;
+      text-decoration: none;
+    }}
+    a:hover {{
+      color: var(--accent);
+    }}
+    .score {{
+      min-width: 112px;
+      text-align: center;
+      border-radius: 16px;
+      padding: 10px 12px;
+      border: 1px solid var(--line);
+    }}
+    .score strong {{
+      display: block;
+      font-size: 24px;
+      line-height: 1;
+    }}
+    .score span {{
+      display: block;
+      margin-top: 6px;
+      font-size: 12px;
+    }}
+    .score.high {{
+      background: var(--green-soft);
+      color: var(--green);
+    }}
+    .score.mid {{
+      background: var(--amber-soft);
+      color: var(--amber);
+    }}
+    .score.low {{
+      background: var(--rose-soft);
+      color: var(--rose);
+    }}
+    .one-line {{
+      margin: 14px 0 18px;
+      font-size: 17px;
+      line-height: 1.7;
+      font-weight: 600;
+    }}
+    .summary-block {{
+      padding: 16px 18px;
+      border-radius: 16px;
+      background: rgba(255,255,255,0.52);
+      border: 1px solid var(--line);
+    }}
+    h3 {{
+      margin: 0 0 8px;
+      font-size: 15px;
+    }}
+    .summary-block p, .panel p, li {{
+      margin: 0;
+      line-height: 1.75;
+      color: #2d2823;
+    }}
+    .grid {{
+      margin-top: 16px;
+      display: grid;
+      grid-template-columns: repeat(2, minmax(0, 1fr));
+      gap: 14px;
+    }}
+    .panel {{
+      padding: 14px 16px;
+      border-radius: 16px;
+      border: 1px solid var(--line);
+      background: rgba(255,255,255,0.56);
+    }}
+    ul {{
+      margin: 0;
+      padding-left: 18px;
+    }}
+    .keywords {{
+      display: flex;
+      gap: 8px;
+      flex-wrap: wrap;
+      margin-top: 16px;
+    }}
+    .chip {{
+      background: #efe9df;
+      color: #574f45;
+    }}
+    .muted {{
+      color: var(--muted);
+    }}
+    @media (max-width: 820px) {{
+      .headline {{
+        flex-direction: column;
+      }}
+      h2 {{
+        max-width: 100%;
+      }}
+      .score {{
+        min-width: 0;
+      }}
+      .grid {{
+        grid-template-columns: 1fr;
+      }}
+    }}
+  </style>
+</head>
+<body>
+  <main class="page">
+    <section class="hero">
+      <h1>AI RSS Daily Digest</h1>
+      <p>生成时间:{escape(generated_at)}。这不是逐篇翻译,而是按信息密度、可读价值和工程/产业启发整理出的中文阅读卡片。</p>
+    </section>
+    <section class="list">
+      {''.join(cards) if cards else '<p class="muted">今天还没有可展示的文章。</p>'}
+    </section>
+  </main>
+</body>
+</html>
+"""
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    output_path.write_text(html_doc, encoding="utf-8")

+ 129 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/extractor.py

@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+from html.parser import HTMLParser
+from pathlib import Path
+from urllib.request import Request, urlopen
+from urllib.error import URLError, HTTPError
+import re
+
+
+USER_AGENT = "rss-digest-bot/0.1"
+NOISE_PATTERNS = [
+    r"subscribe",
+    r"sign in",
+    r"sign up",
+    r"cookie",
+    r"all rights reserved",
+    r"previous post",
+    r"next post",
+    r"related posts?",
+    r"table of contents",
+    r"share this",
+]
+
+
+class _HTMLToTextParser(HTMLParser):
+    def __init__(self) -> None:
+        super().__init__()
+        self._skip_depth = 0
+        self.parts: list[str] = []
+
+    def handle_starttag(self, tag: str, attrs) -> None:
+        attr_text = " ".join(f"{key}={value}" for key, value in attrs)
+        attr_text_lower = attr_text.lower()
+
+        if tag in {"script", "style", "noscript", "svg", "form"}:
+            self._skip_depth += 1
+            return
+
+        if any(flag in attr_text_lower for flag in ["nav", "footer", "sidebar", "comment", "share", "promo", "subscribe"]):
+            self._skip_depth += 1
+            return
+
+        if tag in {"p", "div", "article", "section", "br", "li", "h1", "h2", "h3", "h4", "blockquote", "pre"}:
+            self.parts.append("\n")
+
+    def handle_endtag(self, tag: str) -> None:
+        if tag in {"script", "style", "noscript", "svg", "form"} and self._skip_depth > 0:
+            self._skip_depth -= 1
+            return
+
+        if tag in {"p", "div", "article", "section", "li", "blockquote", "pre"}:
+            self.parts.append("\n")
+
+    def handle_data(self, data: str) -> None:
+        if self._skip_depth > 0:
+            return
+
+        text = data.strip()
+        if not text:
+            return
+        self.parts.append(text + " ")
+
+
+def fetch_html(url: str, timeout: int) -> str:
+    request = Request(url, headers={"User-Agent": USER_AGENT})
+    with urlopen(request, timeout=timeout) as response:
+        content_type = response.headers.get_content_charset() or "utf-8"
+        return response.read().decode(content_type, errors="replace")
+
+
+def _extract_candidate_html(html_text: str) -> str:
+    patterns = [
+        r"<article\b[^>]*>(.*?)</article>",
+        r"<main\b[^>]*>(.*?)</main>",
+        r"<body\b[^>]*>(.*?)</body>",
+    ]
+    for pattern in patterns:
+        match = re.search(pattern, html_text, re.IGNORECASE | re.DOTALL)
+        if match:
+            return match.group(1)
+    return html_text
+
+
+def _clean_line(line: str) -> str:
+    line = re.sub(r"\s+", " ", line).strip()
+    if len(line) < 30:
+        return ""
+    lower = line.lower()
+    if any(re.search(pattern, lower) for pattern in NOISE_PATTERNS):
+        return ""
+    return line
+
+
+def _dedupe_preserve_order(lines: list[str]) -> list[str]:
+    seen: set[str] = set()
+    output: list[str] = []
+    for line in lines:
+        if line in seen:
+            continue
+        seen.add(line)
+        output.append(line)
+    return output
+
+
+def html_to_text(html_text: str) -> str:
+    candidate = _extract_candidate_html(html_text)
+    parser = _HTMLToTextParser()
+    parser.feed(candidate)
+    text = "".join(parser.parts)
+    text = re.sub(r"\n{3,}", "\n\n", text)
+    text = re.sub(r"[ \t]{2,}", " ", text)
+    raw_lines = [segment.strip() for segment in text.splitlines()]
+    cleaned_lines = [_clean_line(line) for line in raw_lines]
+    filtered_lines = [line for line in cleaned_lines if line]
+    filtered_lines = _dedupe_preserve_order(filtered_lines)
+    return "\n\n".join(filtered_lines).strip()
+
+
+def write_text(path: Path, content: str) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(content, encoding="utf-8")
+
+
+def fetch_and_extract(url: str, timeout: int) -> tuple[str, str]:
+    try:
+        html_text = fetch_html(url, timeout)
+    except (URLError, HTTPError, TimeoutError):
+        return "", ""
+    return html_text, html_to_text(html_text)

+ 128 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/feeds.py

@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from email.utils import parsedate_to_datetime
+from http.client import RemoteDisconnected
+from urllib.request import Request, urlopen
+from urllib.error import URLError, HTTPError
+from xml.etree import ElementTree as ET
+import html
+
+
+USER_AGENT = "rss-digest-bot/0.1"
+
+
+@dataclass(slots=True)
+class FeedEntry:
+    title: str
+    link: str
+    published_at: str
+    summary: str
+
+
+def fetch_text(url: str, timeout: int) -> str:
+    request = Request(url, headers={"User-Agent": USER_AGENT})
+    with urlopen(request, timeout=timeout) as response:
+        content_type = response.headers.get_content_charset() or "utf-8"
+        return response.read().decode(content_type, errors="replace")
+
+
+def normalize_datetime(value: str) -> str:
+    if not value:
+        return ""
+
+    try:
+        dt = parsedate_to_datetime(value)
+        if dt.tzinfo is None:
+            dt = dt.replace(tzinfo=timezone.utc)
+        return dt.astimezone(timezone.utc).isoformat()
+    except (TypeError, ValueError):
+        pass
+
+    for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d"):
+        try:
+            dt = datetime.strptime(value, fmt)
+            if dt.tzinfo is None:
+                dt = dt.replace(tzinfo=timezone.utc)
+            return dt.astimezone(timezone.utc).isoformat()
+        except ValueError:
+            continue
+    return value
+
+
+def _strip_namespace(tag: str) -> str:
+    return tag.split("}", 1)[-1]
+
+
+def _find_text(node: ET.Element, tag_name: str) -> str:
+    for child in node.iter():
+        if _strip_namespace(child.tag) == tag_name and child.text:
+            return child.text.strip()
+    return ""
+
+
+def _find_link(node: ET.Element) -> str:
+    fallback = ""
+    for child in node.iter():
+        if _strip_namespace(child.tag) != "link":
+            continue
+        href = child.attrib.get("href")
+        rel = child.attrib.get("rel", "").strip().lower()
+        if href and rel == "alternate":
+            return href.strip()
+        if href:
+            if not fallback or rel not in {"self", "hub"}:
+                fallback = href.strip()
+            continue
+        if child.text and not fallback:
+            fallback = child.text.strip()
+    return fallback
+
+
+def _find_summary(node: ET.Element) -> str:
+    for tag_name in ("summary", "description", "content", "content:encoded"):
+        text = _find_text(node, tag_name)
+        if text:
+            return html.unescape(text)
+    return ""
+
+
+def parse_feed(xml_text: str) -> list[FeedEntry]:
+    root = ET.fromstring(xml_text)
+    entries: list[FeedEntry] = []
+
+    for node in root.iter():
+        local_name = _strip_namespace(node.tag)
+        if local_name not in {"item", "entry"}:
+            continue
+
+        title = html.unescape(_find_text(node, "title") or "Untitled")
+        link = _find_link(node)
+        published = normalize_datetime(
+            _find_text(node, "published")
+            or _find_text(node, "updated")
+            or _find_text(node, "pubDate")
+        )
+        summary = _find_summary(node)
+
+        if link:
+            entries.append(
+                FeedEntry(
+                    title=title,
+                    link=link,
+                    published_at=published,
+                    summary=summary,
+                )
+            )
+    return entries
+
+
+def fetch_feed_entries(feed_url: str, timeout: int) -> list[FeedEntry]:
+    try:
+        xml_text = fetch_text(feed_url, timeout)
+        entries = parse_feed(xml_text)
+        entries.sort(key=lambda item: item.published_at or "", reverse=True)
+        return entries
+    except (ET.ParseError, URLError, HTTPError, TimeoutError, RemoteDisconnected, ConnectionResetError):
+        return []

+ 151 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/llm.py

@@ -0,0 +1,151 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from urllib.request import Request, urlopen
+import json
+import re
+
+
+@dataclass(slots=True)
+class LLMClient:
+    model_name: str
+    api_key: str
+    base_url: str
+    timeout_seconds: int
+    json_mode: bool = True
+
+    def is_enabled(self) -> bool:
+        return bool(self.model_name and self.api_key and self.base_url)
+
+    def chat(self, system_prompt: str, user_prompt: str) -> str:
+        if not self.is_enabled():
+            raise RuntimeError("LLM client is not configured. Check .env.")
+
+        payload = {
+            "model": self.model_name,
+            "temperature": 0.2,
+            "messages": [
+                {"role": "system", "content": system_prompt},
+                {"role": "user", "content": user_prompt},
+            ],
+        }
+        if self.json_mode:
+            payload["response_format"] = {"type": "json_object"}
+
+        body = json.dumps(payload).encode("utf-8")
+        request = Request(
+            f"{self.base_url}/chat/completions",
+            data=body,
+            headers={
+                "Authorization": f"Bearer {self.api_key}",
+                "Content-Type": "application/json",
+            },
+            method="POST",
+        )
+
+        with urlopen(request, timeout=self.timeout_seconds) as response:
+            raw = response.read().decode("utf-8", errors="replace")
+            data = json.loads(raw)
+            choices = data.get("choices") or []
+            if not choices:
+                raise RuntimeError(f"Unexpected LLM response: {raw}")
+            message = choices[0].get("message") or {}
+            return (message.get("content") or "").strip()
+
+
+SUMMARY_SYSTEM_PROMPT = """
+你是一名高标准的中文技术简报编辑,面向持续跟踪 AI/LLM、工程实践、科技产业判断的读者。
+
+你的任务不是直译文章,而是输出高质量的结构化中文阅读卡片。
+
+规则:
+1. 只输出 JSON,不要输出任何额外解释。
+2. 对技术文章优先提炼:解决的问题、方法、限制、工程意义。
+3. 对产业文章优先提炼:核心判断、依据、商业影响、可能偏差。
+4. 如果文章信息密度不高,要明确给低分,并建议跳过。
+5. 语言要自然、准确、克制,避免空话。
+6. 关键词尽量保留英文术语原词。
+"""
+
+
+TRANSLATION_SYSTEM_PROMPT = """
+你是一名专业技术翻译编辑。你的任务是把英文技术或商业文章翻译成自然、准确、适合中文读者的版本。
+
+规则:
+1. 保留关键术语英文原词,并在首次出现时给出中文说明。
+2. 不要漏掉重要限定条件和结论。
+3. 不要过度润色,不要改变原意。
+4. 只输出 JSON。
+"""
+
+
+def build_summary_prompt(title: str, source_name: str, category: str, article_text: str) -> str:
+    trimmed = article_text[:14000]
+    return f"""
+请阅读下面文章,并输出一个 JSON 对象,字段必须完整存在。
+
+文章标题: {title}
+来源: {source_name}
+分类: {category}
+
+JSON schema:
+{{
+  "article_type": "技术实战 | 模型/产品更新 | 行业评论 | 商业分析 | 研究长文 | 资讯公告",
+  "score": 0-100 的整数,
+  "worth_reading": "建议细读 | 选择性阅读 | 可先跳过",
+  "one_line": "一句话结论,20-40字",
+  "summary": "4-6句中文摘要,直接告诉我这篇文章讲了什么",
+  "key_points": ["3条关键点"],
+  "why_it_matters": "这篇文章对持续学习 AI/LLM 和科技产业的人为什么重要",
+  "engineering_takeaway": "如果偏技术,就写工程启发;如果偏产业,就写实际决策启发",
+  "business_signal": "如果偏产业或产品竞争,就写商业信号;否则给出与产业相关的简短判断",
+  "limitations": "作者可能忽略的地方、适用边界或文章局限",
+  "keywords": ["3-5个关键词"],
+  "recommended_action": "我接下来最适合做什么:细读原文 / 只看摘要 / 跳过即可"
+}}
+
+打分规则:
+- 85-100: 高信息密度,值得优先读
+- 70-84: 有价值,可以看
+- 50-69: 有一点价值,但不必优先
+- 0-49: 噪音偏多,可跳过
+
+文章正文:
+{trimmed}
+"""
+
+
+def build_translation_prompt(title: str, article_text: str) -> str:
+    trimmed = article_text[:16000]
+    return f"""
+请把下面这篇英文文章翻译成自然、准确、适合中文技术读者阅读的中文。
+
+输出 JSON:
+{{
+  "translation": "完整中文译文"
+}}
+
+要求:
+1. 保留关键观点,不要遗漏。
+2. 术语首次出现时保留英文原词。
+3. 段落清晰,语言自然。
+
+标题: {title}
+
+正文:
+{trimmed}
+"""
+
+
+def parse_json_response(text: str) -> dict:
+    text = text.strip()
+    if not text:
+        raise ValueError("Empty LLM response")
+
+    try:
+        return json.loads(text)
+    except json.JSONDecodeError:
+        match = re.search(r"\{.*\}", text, re.DOTALL)
+        if not match:
+            raise
+        return json.loads(match.group(0))

+ 443 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/pipeline.py

@@ -0,0 +1,443 @@
+from __future__ import annotations
+
+from datetime import datetime
+from pathlib import Path
+import hashlib
+from time import perf_counter
+from typing import Any
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+from rss_digest.config import build_config, load_sources
+from rss_digest.db import (
+    bootstrap_existing_digest_delivery,
+    connect,
+    get_articles_by_status,
+    get_undelivered_ready_articles,
+    init_db,
+    mark_digest_delivered,
+    update_article_paths,
+    update_article_texts,
+    upsert_article,
+)
+from rss_digest.digest import render_html
+from rss_digest.extractor import fetch_and_extract, html_to_text, write_text
+from rss_digest.feeds import fetch_feed_entries
+from rss_digest.llm import (
+    LLMClient,
+    SUMMARY_SYSTEM_PROMPT,
+    TRANSLATION_SYSTEM_PROMPT,
+    build_summary_prompt,
+    build_translation_prompt,
+    parse_json_response,
+)
+
+
+def _slugify(value: str) -> str:
+    cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in value)
+    while "--" in cleaned:
+        cleaned = cleaned.replace("--", "-")
+    return cleaned.strip("-") or "article"
+
+
+def _article_stem(article_id: int, title: str) -> str:
+    slug = _slugify(title)[:64]
+    return f"{article_id:06d}_{slug}"
+
+
+def _short_hash(value: str) -> str:
+    return hashlib.sha1(value.encode("utf-8")).hexdigest()[:10]
+
+
+def _string_list(value, limit: int = 5) -> list[str]:
+    if not isinstance(value, list):
+        return []
+    output: list[str] = []
+    for item in value[:limit]:
+        if item is None:
+            continue
+        text = str(item).strip()
+        if text:
+            output.append(text)
+    return output
+
+
+def _normalize_summary_payload(payload: dict) -> dict:
+    score = payload.get("score", 0)
+    try:
+        score = max(0, min(100, int(score)))
+    except (TypeError, ValueError):
+        score = 0
+
+    return {
+        "article_type": str(payload.get("article_type", "未分类")).strip() or "未分类",
+        "score": score,
+        "worth_reading": str(payload.get("worth_reading", "选择性阅读")).strip() or "选择性阅读",
+        "one_line": str(payload.get("one_line", "暂无一句话结论")).strip() or "暂无一句话结论",
+        "summary": str(payload.get("summary", "暂无摘要")).strip() or "暂无摘要",
+        "key_points": _string_list(payload.get("key_points"), limit=4),
+        "why_it_matters": str(payload.get("why_it_matters", "")).strip(),
+        "engineering_takeaway": str(payload.get("engineering_takeaway", "")).strip(),
+        "business_signal": str(payload.get("business_signal", "")).strip(),
+        "limitations": str(payload.get("limitations", "")).strip(),
+        "keywords": _string_list(payload.get("keywords"), limit=5),
+        "recommended_action": str(payload.get("recommended_action", "")).strip(),
+    }
+
+
+def discover_articles(conn, cfg) -> int:
+    discovered = 0
+    sources = load_sources(cfg.sources_file)[: cfg.rss_source_limit]
+    print(
+        f"[1/4] Fetching feeds from {len(sources)} sources..."
+        f" concurrency={cfg.rss_fetch_concurrency}"
+        f" timeout={cfg.request_timeout_seconds}s"
+    )
+
+    def fetch_source(source: dict[str, str]) -> tuple[dict[str, str], list[Any]]:
+        entries = fetch_feed_entries(source["feed_url"], cfg.request_timeout_seconds)
+        return source, entries[: cfg.rss_entries_per_source]
+
+    fetched_entries = 0
+    with ThreadPoolExecutor(max_workers=cfg.rss_fetch_concurrency) as executor:
+        futures = [executor.submit(fetch_source, source) for source in sources]
+        for future in as_completed(futures):
+            source, entries = future.result()
+            fetched_entries += len(entries)
+            for entry in entries:
+                added = upsert_article(
+                    conn,
+                    source_name=source["name"],
+                    category=source["category"],
+                    title=entry.title,
+                    link=entry.link,
+                    published_at=entry.published_at,
+                    feed_summary=entry.summary[:2000],
+                )
+                if added:
+                    discovered += 1
+    print(f"[1/4] Feed fetch complete: entries={fetched_entries} | new={discovered}")
+    return discovered
+
+
+def _select_article_rows(conn, cfg, statuses: list[str], limit: int) -> tuple[list[dict], dict[str, int]]:
+    rows: list[dict] = []
+    counts: dict[str, int] = {}
+    for status in statuses:
+        status_rows = get_articles_by_status(conn, status, limit)
+        counts[status] = len(status_rows)
+        rows.extend(status_rows)
+
+    deduped: list[dict] = []
+    seen_ids: set[int] = set()
+    for row in rows:
+        if row["id"] in seen_ids:
+            continue
+        seen_ids.add(row["id"])
+        deduped.append(row)
+        if len(deduped) >= limit:
+            break
+    return deduped, counts
+
+
+def extract_articles(conn, cfg) -> int:
+    deduped_rows, counts = _select_article_rows(
+        conn,
+        cfg,
+        statuses=["discovered", "fetch_failed"],
+        limit=cfg.max_articles_per_run,
+    )
+
+    print(
+        f"[2/4] Extracting article bodies: {len(deduped_rows)} item(s)"
+        f" | new={counts.get('discovered', 0)}"
+        f" | retry_failed={counts.get('fetch_failed', 0)}"
+        f" | concurrency={cfg.rss_fetch_concurrency}"
+    )
+
+    def extract_row(row: dict) -> tuple[dict, str, str]:
+        html_text, article_text = fetch_and_extract(row["link"], cfg.request_timeout_seconds)
+        return row, html_text, article_text
+
+    extracted = 0
+    failed = 0
+    with ThreadPoolExecutor(max_workers=cfg.rss_fetch_concurrency) as executor:
+        futures = [executor.submit(extract_row, row) for row in deduped_rows]
+        for future in as_completed(futures):
+            row, html_text, article_text = future.result()
+            if not article_text:
+                article_text = html_to_text(row.get("feed_summary") or "")
+                html_text = row.get("feed_summary") or ""
+                if not article_text:
+                    update_article_paths(conn, row["id"], status="fetch_failed")
+                    failed += 1
+                    continue
+
+            stem = _article_stem(row["id"], row["title"])
+            html_path = cfg.raw_dir / f"{stem}_{_short_hash(row['link'])}.html"
+            text_path = cfg.extracted_dir / f"{stem}.txt"
+            write_text(html_path, html_text)
+            write_text(text_path, article_text)
+            update_article_paths(
+                conn,
+                row["id"],
+                raw_html_path=str(html_path),
+                extracted_text_path=str(text_path),
+                status="extracted",
+            )
+            extracted += 1
+    print(f"[2/4] Article extraction complete: extracted={extracted} | failed={failed}")
+    return extracted
+
+
+def _rows_for_resummarize(conn, cfg) -> list[dict]:
+    if not cfg.resummarize_existing:
+        return []
+
+    candidates = []
+    for row in get_undelivered_ready_articles(conn, "__resummarize__", limit=max(cfg.max_articles_per_run * 3, 36)):
+        if row.get("summary_data") and row.get("article_type") and row.get("article_score") is not None:
+            continue
+        candidates.append(row)
+        if len(candidates) >= cfg.max_articles_per_run:
+            break
+    return candidates
+
+
+def summarize_articles(conn, cfg, llm_client: LLMClient, translation_client: LLMClient) -> list[int]:
+    summary_limit = min(cfg.max_articles_per_run, cfg.rss_max_summary_articles_per_run)
+    extracted_rows = get_articles_by_status(conn, "extracted", summary_limit)
+    retry_rows = get_articles_by_status(conn, "summary_failed", summary_limit)
+    resummarize_rows = _rows_for_resummarize(conn, cfg)
+    rows = extracted_rows + retry_rows + resummarize_rows
+
+    deduped: list[dict] = []
+    seen_ids: set[int] = set()
+    for row in rows:
+        if row["id"] in seen_ids:
+            continue
+        seen_ids.add(row["id"])
+        deduped.append(row)
+        if len(deduped) >= summary_limit:
+            break
+
+    print(
+        f"[3/4] Summarizing articles: {len(deduped)} item(s)"
+        f" | new={len(extracted_rows)}"
+        f" | retry_failed={len(retry_rows)}"
+        f" | resummarize={len(resummarize_rows)}"
+        f" | batch_size={cfg.rss_ai_batch_size}"
+        f" | ai_concurrency={cfg.rss_ai_max_concurrency}"
+    )
+
+    def summarize_row(row: dict) -> tuple[dict, dict | None, str | None, str | None, str | None]:
+        text_path_value = row.get("extracted_text_path")
+        if not text_path_value:
+            return row, None, None, None, "extract_missing"
+
+        text_path = Path(text_path_value)
+        if not text_path.exists():
+            return row, None, None, None, "extract_missing"
+
+        article_text = text_path.read_text(encoding="utf-8")
+        if not article_text.strip():
+            return row, None, None, None, "empty_text"
+
+        try:
+            summary_payload = parse_json_response(
+                llm_client.chat(
+                    SUMMARY_SYSTEM_PROMPT,
+                    build_summary_prompt(
+                        title=row["title"],
+                        source_name=row["source_name"],
+                        category=row["category"],
+                        article_text=article_text,
+                    ),
+                )
+            )
+            normalized = _normalize_summary_payload(summary_payload)
+        except Exception as exc:
+            return row, {"summary": f"摘要生成失败: {exc}"}, None, None, "summary_failed"
+
+        translation_cn = None
+        translated_path = None
+        if cfg.fetch_full_translation and normalized["score"] >= 85:
+            try:
+                translation_payload = parse_json_response(
+                    translation_client.chat(
+                        TRANSLATION_SYSTEM_PROMPT,
+                        build_translation_prompt(row["title"], article_text),
+                    )
+                )
+                translation_cn = str(translation_payload.get("translation", "")).strip()
+                if translation_cn:
+                    translated_path_obj = cfg.translated_dir / f"{_article_stem(row['id'], row['title'])}.md"
+                    write_text(translated_path_obj, translation_cn)
+                    translated_path = str(translated_path_obj)
+            except Exception as exc:
+                translation_cn = f"全文翻译失败: {exc}"
+
+        return row, normalized, translation_cn, translated_path, None
+
+    completed_ids: list[int] = []
+    failed_count = 0
+    for batch_start in range(0, len(deduped), cfg.rss_ai_batch_size):
+        batch = deduped[batch_start : batch_start + cfg.rss_ai_batch_size]
+        with ThreadPoolExecutor(max_workers=cfg.rss_ai_max_concurrency) as executor:
+            futures = [executor.submit(summarize_row, row) for row in batch]
+            for future in as_completed(futures):
+                row, normalized, translation_cn, translated_path, error_status = future.result()
+                if error_status:
+                    update_article_texts(
+                        conn,
+                        row["id"],
+                        summary_cn=(normalized or {}).get("summary"),
+                        status=error_status,
+                    )
+                    failed_count += 1
+                    continue
+
+                if not normalized:
+                    update_article_texts(conn, row["id"], status="summary_failed")
+                    failed_count += 1
+                    continue
+
+                update_article_texts(
+                    conn,
+                    row["id"],
+                    summary_cn=normalized["summary"],
+                    translation_cn=translation_cn,
+                    translated_text_path=translated_path,
+                    article_type=normalized["article_type"],
+                    article_score=normalized["score"],
+                    worth_reading=normalized["worth_reading"],
+                    one_line=normalized["one_line"],
+                    summary_data=normalized,
+                    status="ready",
+                )
+                completed_ids.append(int(row["id"]))
+    print(f"[3/4] Article summarization complete: summarized={len(completed_ids)} | failed={failed_count}")
+    return completed_ids
+
+
+def build_daily_digest(conn, cfg, newly_ready_ids: list[int]) -> tuple[Path, int, bool, int]:
+    today = datetime.now().strftime("%Y-%m-%d")
+    output_path = cfg.digests_dir / f"digest_{today}.html"
+
+    if output_path.exists():
+        bootstrapped = bootstrap_existing_digest_delivery(
+            conn,
+            today,
+            exclude_ids=set(newly_ready_ids),
+        )
+        if bootstrapped:
+            print(f"[4/4] Bootstrapped delivered history for {bootstrapped} already-rendered article(s)")
+    else:
+        bootstrapped = 0
+
+    recent_rows = get_undelivered_ready_articles(
+        conn,
+        today,
+        limit=max(cfg.max_articles_per_run * 5, cfg.rss_max_digest_articles * 10, 200),
+    )
+    before_filter_count = len(recent_rows)
+    recent_rows = [
+        row
+        for row in recent_rows
+        if int(row.get("article_score") or 0) >= cfg.rss_relevance_threshold
+    ]
+    recent_rows.sort(
+        key=lambda row: (
+            int(row.get("article_score") or 0),
+            row.get("published_at") or "",
+            int(row.get("id") or 0),
+        ),
+        reverse=True,
+    )
+    recent_rows = recent_rows[: cfg.rss_max_digest_articles]
+    no_new_articles = not recent_rows
+    print(
+        f"[4/4] Rendering digest with {len(recent_rows)} new article card(s)"
+        f" | candidates={before_filter_count}"
+        f" | threshold={cfg.rss_relevance_threshold}"
+        f" | max_digest={cfg.rss_max_digest_articles}"
+    )
+    render_html(recent_rows, output_path)
+    mark_digest_delivered(conn, today, [int(row["id"]) for row in recent_rows])
+    return output_path, len(recent_rows), no_new_articles, bootstrapped
+
+
+def run_pipeline(root_dir: Path, data_root: Path | None = None) -> dict[str, Any]:
+    total_started = perf_counter()
+    cfg = build_config(root_dir, data_root)
+    conn = connect(cfg.db_path)
+    init_db(conn)
+
+    llm_client = LLMClient(
+        model_name=cfg.model_name,
+        api_key=cfg.api_key,
+        base_url=cfg.base_url,
+        timeout_seconds=cfg.llm_timeout_seconds,
+    )
+    translation_client = LLMClient(
+        model_name=cfg.translation_model_name or cfg.model_name,
+        api_key=cfg.api_key,
+        base_url=cfg.base_url,
+        timeout_seconds=cfg.llm_timeout_seconds,
+    )
+
+    timings: dict[str, float] = {}
+
+    started = perf_counter()
+    discovered = discover_articles(conn, cfg)
+    timings["discover_seconds"] = round(perf_counter() - started, 3)
+
+    started = perf_counter()
+    extracted = extract_articles(conn, cfg)
+    timings["extract_seconds"] = round(perf_counter() - started, 3)
+
+    summarized_ids: list[int] = []
+    llm_enabled = llm_client.is_enabled()
+    if llm_enabled:
+        started = perf_counter()
+        summarized_ids = summarize_articles(conn, cfg, llm_client, translation_client)
+        timings["summarize_seconds"] = round(perf_counter() - started, 3)
+    else:
+        print("[3/4] Summarizing articles skipped: LLM client is not configured.")
+        timings["summarize_seconds"] = 0.0
+
+    started = perf_counter()
+    digest_path, digest_article_count, no_new_articles, bootstrapped_delivered = build_daily_digest(
+        conn,
+        cfg,
+        summarized_ids,
+    )
+    timings["digest_seconds"] = round(perf_counter() - started, 3)
+    timings["total_seconds"] = round(perf_counter() - total_started, 3)
+
+    print(
+        "[rss_digest] Pipeline summary: "
+        f"discovered={discovered} | extracted={extracted} | summarized={len(summarized_ids)} | "
+        f"digest_articles={digest_article_count} | digest={digest_path} | timings={timings}"
+    )
+
+    return {
+        "discovered": discovered,
+        "extracted": extracted,
+        "summarized": len(summarized_ids),
+        "summarized_ids": summarized_ids,
+        "digest_article_count": digest_article_count,
+        "no_new_articles": no_new_articles,
+        "bootstrapped_delivered": bootstrapped_delivered,
+        "llm_enabled": llm_enabled,
+        "digest_path": str(digest_path),
+        "max_articles_per_run": cfg.max_articles_per_run,
+        "rss_fetch_concurrency": cfg.rss_fetch_concurrency,
+        "rss_source_limit": cfg.rss_source_limit,
+        "rss_entries_per_source": cfg.rss_entries_per_source,
+        "rss_ai_batch_size": cfg.rss_ai_batch_size,
+        "rss_ai_max_concurrency": cfg.rss_ai_max_concurrency,
+        "rss_relevance_threshold": cfg.rss_relevance_threshold,
+        "rss_max_summary_articles_per_run": cfg.rss_max_summary_articles_per_run,
+        "rss_max_digest_articles": cfg.rss_max_digest_articles,
+        "timings": timings,
+    }

+ 254 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/ui_server.py

@@ -0,0 +1,254 @@
+from __future__ import annotations
+
+from datetime import datetime
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from threading import Lock, Thread
+from urllib.parse import parse_qs, urlparse
+import json
+import sys
+
+from rss_digest.config import build_config
+from rss_digest.db import connect, get_recent_articles, init_db
+from rss_digest.pipeline import run_pipeline
+
+
+class UIState:
+    def __init__(self, root_dir: Path, data_root: Path | None = None) -> None:
+        self.root_dir = root_dir
+        self.data_root = data_root
+        self.lock = Lock()
+        self.running = False
+        self.last_started_at = None
+        self.last_finished_at = None
+        self.last_error = None
+        self.last_digest_path = None
+
+    def start_run(self) -> bool:
+        with self.lock:
+            if self.running:
+                return False
+            self.running = True
+            self.last_started_at = datetime.now().isoformat(timespec="seconds")
+            self.last_error = None
+            return True
+
+    def finish_run(self, digest_path: str | None, error: str | None) -> None:
+        with self.lock:
+            self.running = False
+            self.last_finished_at = datetime.now().isoformat(timespec="seconds")
+            self.last_digest_path = digest_path
+            self.last_error = error
+
+    def snapshot(self) -> dict:
+        with self.lock:
+            return {
+                "running": self.running,
+                "last_started_at": self.last_started_at,
+                "last_finished_at": self.last_finished_at,
+                "last_error": self.last_error,
+                "last_digest_path": self.last_digest_path,
+            }
+
+
+def _read_recent_articles(root_dir: Path, data_root: Path | None = None, limit: int = 12) -> list[dict]:
+    cfg = build_config(root_dir, data_root)
+    conn = connect(cfg.db_path)
+    init_db(conn)
+    return get_recent_articles(conn, limit=limit)
+
+
+def _read_env_summary(root_dir: Path, data_root: Path | None = None) -> dict:
+    cfg = build_config(root_dir, data_root)
+    return {
+        "summary_model": cfg.model_name,
+        "translation_model": cfg.translation_model_name or cfg.model_name,
+        "max_articles_per_run": cfg.max_articles_per_run,
+        "llm_timeout_seconds": cfg.llm_timeout_seconds,
+        "resummarize_existing": cfg.resummarize_existing,
+        "fetch_full_translation": cfg.fetch_full_translation,
+    }
+
+
+def _latest_digest_path(root_dir: Path, data_root: Path | None = None) -> str | None:
+    if data_root is None:
+        data_root = root_dir / "data"
+    digest_dir = data_root / "runs" / "digests"
+    files = sorted(digest_dir.glob("digest_*.html"), key=lambda p: p.stat().st_mtime, reverse=True)
+    return str(files[0]) if files else None
+
+
+def _run_pipeline_background(state: UIState) -> None:
+    digest_path = None
+    error = None
+    try:
+        run_pipeline(state.root_dir, state.data_root)
+        digest_path = _latest_digest_path(state.root_dir, state.data_root)
+    except Exception as exc:
+        error = str(exc)
+    finally:
+        state.finish_run(digest_path, error)
+
+
+def build_handler(root_dir: Path, state: UIState):
+    class Handler(BaseHTTPRequestHandler):
+        def _json(self, payload: dict, status: int = 200) -> None:
+            body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+            self.send_response(status)
+            self.send_header("Content-Type", "application/json; charset=utf-8")
+            self.send_header("Content-Length", str(len(body)))
+            self.end_headers()
+            self.wfile.write(body)
+
+        def _html(self, body: str, status: int = 200) -> None:
+            data = body.encode("utf-8")
+            self.send_response(status)
+            self.send_header("Content-Type", "text/html; charset=utf-8")
+            self.send_header("Content-Length", str(len(data)))
+            self.end_headers()
+            self.wfile.write(data)
+
+        def do_GET(self) -> None:
+            parsed = urlparse(self.path)
+            if parsed.path == "/":
+                snapshot = state.snapshot()
+                env_summary = _read_env_summary(root_dir, state.data_root)
+                body = f"""<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>RSS Digest UI</title>
+  <style>
+    body {{ font-family: "Segoe UI","Microsoft YaHei",sans-serif; margin: 0; background: #f3eee3; color: #201c18; }}
+    .page {{ max-width: 1100px; margin: 0 auto; padding: 24px 18px 40px; }}
+    .hero, .panel {{ background: #fffdf8; border: 1px solid #dccfbc; border-radius: 18px; padding: 18px 20px; box-shadow: 0 10px 20px rgba(0,0,0,.03); }}
+    .hero h1 {{ margin: 0 0 8px; }}
+    .grid {{ display: grid; grid-template-columns: 1.1fr .9fr; gap: 16px; margin-top: 16px; }}
+    .row {{ display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }}
+    button, a.btn {{ background: #9a4f2b; color: #fff; border: 0; border-radius: 999px; padding: 10px 16px; text-decoration: none; cursor: pointer; }}
+    a.btn.secondary, button.secondary {{ background: #efe3d5; color: #6a4a34; }}
+    .meta {{ color: #655c52; line-height: 1.8; }}
+    .articles {{ margin-top: 16px; display: grid; gap: 12px; }}
+    .card {{ background: #fffaf2; border: 1px solid #e1d4c1; border-radius: 16px; padding: 14px 16px; }}
+    .muted {{ color: #6f665c; }}
+    .score {{ float: right; font-weight: 700; color: #2f6b4f; }}
+    iframe {{ width: 100%; height: 620px; border: 1px solid #dccfbc; border-radius: 16px; background: white; }}
+    @media (max-width: 900px) {{ .grid {{ grid-template-columns: 1fr; }} iframe {{ height: 420px; }} }}
+  </style>
+</head>
+<body>
+  <main class="page">
+    <section class="hero">
+      <h1>RSS Digest 控制台</h1>
+      <div class="meta">
+        <div>摘要模型:{env_summary["summary_model"] or "未配置"}</div>
+        <div>翻译模型:{env_summary["translation_model"] or "未配置"}</div>
+        <div>每轮文章数:{env_summary["max_articles_per_run"]} | 重做旧摘要:{env_summary["resummarize_existing"]} | 全文翻译:{env_summary["fetch_full_translation"]}</div>
+        <div>运行状态:{"运行中" if snapshot["running"] else "空闲"}</div>
+        <div>最近启动:{snapshot["last_started_at"] or "暂无"} | 最近完成:{snapshot["last_finished_at"] or "暂无"}</div>
+        <div>最近错误:{snapshot["last_error"] or "无"}</div>
+      </div>
+      <div class="row">
+        <button onclick="runDigest()">运行一次</button>
+        <button class="secondary" onclick="refreshStatus()">刷新状态</button>
+        <a class="btn secondary" href="/digest" target="_blank">打开最新 HTML</a>
+      </div>
+    </section>
+    <section class="grid">
+      <section class="panel">
+        <h2>最近文章</h2>
+        <div id="articles" class="articles"></div>
+      </section>
+      <section class="panel">
+        <h2>最新日报预览</h2>
+        <iframe src="/digest"></iframe>
+      </section>
+    </section>
+  </main>
+  <script>
+    async function refreshStatus() {{
+      const res = await fetch('/api/status');
+      const data = await res.json();
+      console.log(data);
+    }}
+    async function loadArticles() {{
+      const res = await fetch('/api/articles');
+      const data = await res.json();
+      const root = document.getElementById('articles');
+      root.innerHTML = '';
+      for (const article of data.articles) {{
+        const div = document.createElement('div');
+        div.className = 'card';
+        div.innerHTML = `
+          <div><strong>${{article.title}}</strong><span class="score">${{article.article_score ?? '-'}} 分</span></div>
+          <div class="muted">${{article.source_name}} · ${{article.category}} · ${{article.worth_reading ?? '未评级'}}</div>
+          <div style="margin-top:8px;">${{article.one_line ?? article.summary_cn ?? '暂无摘要'}}</div>
+          <div style="margin-top:8px;"><a href="${{article.link}}" target="_blank">原文</a></div>
+        `;
+        root.appendChild(div);
+      }}
+    }}
+    async function runDigest() {{
+      const res = await fetch('/api/run', {{method: 'POST'}});
+      const data = await res.json();
+      alert(data.message);
+      await loadArticles();
+    }}
+    loadArticles();
+  </script>
+</body>
+</html>"""
+                self._html(body)
+                return
+
+            if parsed.path == "/api/status":
+                payload = state.snapshot()
+                payload["latest_digest_path"] = _latest_digest_path(root_dir, state.data_root)
+                payload["env"] = _read_env_summary(root_dir, state.data_root)
+                self._json(payload)
+                return
+
+            if parsed.path == "/api/articles":
+                articles = _read_recent_articles(root_dir, state.data_root, limit=12)
+                self._json({"articles": articles})
+                return
+
+            if parsed.path == "/digest":
+                digest_path = _latest_digest_path(root_dir, state.data_root)
+                if not digest_path:
+                    self._html("<p>暂无日报,请先运行一次任务。</p>", status=200)
+                    return
+                data = Path(digest_path).read_text(encoding="utf-8")
+                self._html(data)
+                return
+
+            self._html("<p>Not found</p>", status=404)
+
+        def do_POST(self) -> None:
+            parsed = urlparse(self.path)
+            if parsed.path == "/api/run":
+                if not state.start_run():
+                    self._json({"ok": False, "message": "任务已经在运行中。"}, status=409)
+                    return
+                Thread(target=_run_pipeline_background, args=(state,), daemon=True).start()
+                self._json({"ok": True, "message": "后台任务已启动。"})
+                return
+            self._json({"ok": False, "message": "Not found"}, status=404)
+
+        def log_message(self, format: str, *args) -> None:
+            return
+
+    return Handler
+
+
+def serve_ui(root_dir: Path, data_root: Path | None = None, host: str = "127.0.0.1", port: int = 8765) -> None:
+    state = UIState(root_dir, data_root)
+    server = ThreadingHTTPServer((host, port), build_handler(root_dir, state))
+    print(f"UI: http://{host}:{port}")
+    try:
+        server.serve_forever()
+    except KeyboardInterrupt:
+        pass
+    finally:
+        server.server_close()

+ 1 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/__init__.py

@@ -0,0 +1 @@
+"""Base package for the chapter16 agent platform."""

+ 14 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/__init__.py

@@ -0,0 +1,14 @@
+from .base import BaseAgent
+from .profiles import default_profiles
+from .registry import AgentRegistry, build_default_registry
+from .adapters.deep_research import DeepResearchAdapter
+from .adapters.rss_digest import RSSDigestAdapter
+
+__all__ = [
+    "AgentRegistry",
+    "BaseAgent",
+    "DeepResearchAdapter",
+    "RSSDigestAdapter",
+    "build_default_registry",
+    "default_profiles",
+]

+ 0 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/adapters/__init__.py


+ 220 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/adapters/deep_research.py

@@ -0,0 +1,220 @@
+from __future__ import annotations
+
+import importlib
+import io
+import sys
+from contextlib import redirect_stdout
+from pathlib import Path
+from time import perf_counter
+from typing import Any
+
+from dotenv import load_dotenv
+
+from backend.agents.base import BaseAgent
+from backend.config import ENV_FILE, ROOT_DIR, settings
+from backend.events import event_logger
+from backend.maintenance import cleanup_deep_research_artifacts
+from backend.memory.base import memory_store
+from backend.models import AgentRequest, AgentResponse
+
+
+class DeepResearchAdapter(BaseAgent):
+    """Expose the built-in DeepResearchAgent as one platform-level agent."""
+
+    def run(self, request: AgentRequest) -> AgentResponse:
+        event_logger.emit("agent_started", agent_id=self.agent_id, task_id=request.task_id)
+        try:
+            output, artifacts = self._run_with_artifacts(request)
+        except Exception as exc:
+            output = f"deep_research 运行失败:{type(exc).__name__}: {exc}"
+            artifacts = {"error": str(exc), "error_type": type(exc).__name__}
+
+        memory_store.add(self.agent_id, f"input={request.input} output={output}")
+        event = event_logger.emit(
+            "agent_completed",
+            agent_id=self.agent_id,
+            task_id=request.task_id,
+            payload={
+                "output_preview": output[:200],
+                "artifact_keys": sorted(artifacts.keys()),
+            },
+        )
+        return AgentResponse(
+            agent_id=self.agent_id,
+            output=output,
+            artifacts=artifacts,
+            events=[event],
+        )
+
+    def _run(self, request: AgentRequest) -> str:
+        output, _ = self._run_with_artifacts(request)
+        return output
+
+    def _run_with_artifacts(self, request: AgentRequest) -> tuple[str, dict[str, Any]]:
+        total_started = perf_counter()
+        stdout_buffer = io.StringIO()
+        timings: dict[str, float] = {}
+
+        cleanup_started = perf_counter()
+        cleanup_stats = cleanup_deep_research_artifacts()
+        timings["cleanup_seconds"] = round(perf_counter() - cleanup_started, 3)
+
+        if request.context.get("mode") == "group_chat":
+            return (
+                "deep_research 是长耗时研究流程。请单独使用 @deep_research 提交明确研究主题。",
+                {"skipped": True, "reason": "batch_guard", "cleanup": cleanup_stats},
+            )
+
+        deep_research_path = Path(settings.chapter14_backend_path).resolve()
+        if not deep_research_path.exists():
+            return (
+                f"DeepResearch 内置源码路径不存在,无法运行 deep_research:{deep_research_path}",
+                {
+                    "ready": False,
+                    "deep_research_path": str(deep_research_path),
+                    "cleanup": cleanup_stats,
+                },
+            )
+
+        if request.context.get("dry_run"):
+            return (
+                "deep_research 已接入内置 DeepResearchAgent,真实运行时会执行搜索调研流程。",
+                {
+                    "ready": True,
+                    "deep_research_path": str(deep_research_path),
+                    "cleanup": cleanup_stats,
+                },
+            )
+
+        topic_preview = request.input.replace("\n", " ")[:120]
+        print(f"[deep_research] start task_id={request.task_id or '-'} topic={topic_preview}")
+
+        started = perf_counter()
+        with redirect_stdout(stdout_buffer):
+            DeepResearchAgent, Configuration = self._load_deep_research_types(deep_research_path)
+        timings["load_deep_research_seconds"] = round(perf_counter() - started, 3)
+        print(f"[deep_research] loaded source={deep_research_path}")
+
+        started = perf_counter()
+        with redirect_stdout(stdout_buffer):
+            config = Configuration.from_env(overrides=self._deep_research_overrides())
+            agent = DeepResearchAgent(config=config)
+        timings["agent_init_seconds"] = round(perf_counter() - started, 3)
+        print(
+            "[deep_research] initialized "
+            f"search={config.search_api.value if hasattr(config.search_api, 'value') else config.search_api} "
+            f"model={config.resolved_model() or '-'}"
+        )
+
+        started = perf_counter()
+        print("[deep_research] researching...")
+        with redirect_stdout(stdout_buffer):
+            result = agent.run(request.input)
+        timings["agent_run_seconds"] = round(perf_counter() - started, 3)
+
+        started = perf_counter()
+        todo_items = [self._serialize_todo(item) for item in result.todo_items]
+        report = (result.report_markdown or result.running_summary or "").strip()
+        completed_items = [
+            item for item in todo_items if item.get("status") == "completed" and item.get("summary")
+        ]
+        skipped_items = [item for item in todo_items if item.get("status") == "skipped"]
+        failed_items = [item for item in todo_items if item.get("status") == "failed"]
+        artifacts: dict[str, Any] = {
+            "report_markdown": report,
+            "todo_items": todo_items,
+            "cleanup": cleanup_stats,
+        }
+        captured_stdout = stdout_buffer.getvalue().strip()
+        if captured_stdout:
+            artifacts["stdout"] = captured_stdout
+        timings["postprocess_seconds"] = round(perf_counter() - started, 3)
+        timings["total_seconds"] = round(perf_counter() - total_started, 3)
+        artifacts["timings"] = timings
+        if todo_items:
+            artifacts["todo_count"] = len(todo_items)
+            artifacts["completed_count"] = len(completed_items)
+            artifacts["skipped_count"] = len(skipped_items)
+            artifacts["failed_count"] = len(failed_items)
+
+        print(
+            "[deep_research] research completed "
+            f"tasks={len(todo_items)} completed={len(completed_items)} "
+            f"skipped={len(skipped_items)} failed={len(failed_items)}"
+        )
+        print(f"[deep_research] report generated chars={len(report)}")
+
+        if todo_items and not completed_items and not report:
+            output = (
+                "搜索员没有拿到可用的搜索总结,因此未返回正式研究报告。\n"
+                "可能原因:搜索后端无结果、网络 API 调用失败,或任务执行阶段没有产出摘要。\n"
+                "请查看后端日志和 data/deep_research/runs 目录下的 task_* 文件。"
+            )
+            print(f"[deep_research] failed seconds={timings['total_seconds']}")
+            return output, artifacts
+
+        if todo_items and not completed_items:
+            artifacts["warning"] = "no_completed_research_tasks"
+
+        output = report or "deep_research 已完成,但没有生成报告正文。"
+        print(f"[deep_research] complete seconds={timings['total_seconds']}")
+        return output, artifacts
+
+    def _load_deep_research_types(self, deep_research_path: Path) -> tuple[type[Any], type[Any]]:
+        path_text = str(deep_research_path)
+        if path_text not in sys.path:
+            sys.path.insert(0, path_text)
+
+        agent_module = importlib.import_module("agent")
+        config_module = importlib.import_module("config")
+        if ENV_FILE.exists():
+            load_dotenv(ENV_FILE, override=True)
+
+        return agent_module.DeepResearchAgent, config_module.Configuration
+
+    def _deep_research_overrides(self) -> dict[str, Any]:
+        overrides: dict[str, Any] = {
+            "notes_workspace": self._resolve_workspace(settings.notes_workspace),
+            "run_workspace": self._resolve_workspace(settings.run_workspace),
+        }
+
+        optional_values = {
+            "llm_provider": settings.llm_provider,
+            "llm_model_id": settings.llm_model_id,
+            "llm_api_key": settings.llm_api_key,
+            "llm_base_url": settings.llm_base_url,
+            "llm_timeout": settings.llm_timeout,
+            "search_api": settings.search_api,
+            "max_web_research_loops": settings.max_web_research_loops,
+            "fetch_full_page": settings.fetch_full_page,
+            "enable_notes": settings.enable_notes,
+            "persist_runs": settings.persist_runs,
+            "cleanup_intermediate_files": settings.cleanup_intermediate_files,
+        }
+        for key, value in optional_values.items():
+            if value is not None:
+                overrides[key] = value
+
+        return overrides
+
+    @staticmethod
+    def _resolve_workspace(value: str) -> str:
+        path = Path(value)
+        if not path.is_absolute():
+            path = ROOT_DIR / path
+        path.mkdir(parents=True, exist_ok=True)
+        return str(path.resolve())
+
+    @staticmethod
+    def _serialize_todo(item: Any) -> dict[str, Any]:
+        return {
+            "id": getattr(item, "id", None),
+            "title": getattr(item, "title", ""),
+            "intent": getattr(item, "intent", ""),
+            "query": getattr(item, "query", ""),
+            "status": getattr(item, "status", ""),
+            "summary": getattr(item, "summary", None),
+            "sources_summary": getattr(item, "sources_summary", None),
+            "note_id": getattr(item, "note_id", None),
+            "note_path": getattr(item, "note_path", None),
+        }

+ 251 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/adapters/rss_digest.py

@@ -0,0 +1,251 @@
+from __future__ import annotations
+
+import importlib
+import io
+import sys
+from contextlib import redirect_stdout
+from datetime import datetime
+from pathlib import Path
+from time import perf_counter
+from typing import Any
+
+from backend.agents.base import BaseAgent
+from backend.config import settings
+from backend.events import event_logger
+from backend.maintenance import cleanup_rss_artifacts
+from backend.memory.base import memory_store
+from backend.models import AgentRequest, AgentResponse
+
+
+class RSSDigestAdapter(BaseAgent):
+    """Expose rss_digest as one information/news platform agent."""
+
+    def run(self, request: AgentRequest) -> AgentResponse:
+        event_logger.emit("agent_started", agent_id=self.agent_id, task_id=request.task_id)
+        try:
+            output, artifacts = self._run_with_artifacts(request)
+        except Exception as exc:
+            output = f"资讯员运行失败:{type(exc).__name__}: {exc}"
+            artifacts = {"error": str(exc), "error_type": type(exc).__name__}
+            print(f"[rss_digest][error] {output}")
+
+        memory_store.add(self.agent_id, f"input={request.input} output={output}")
+        event = event_logger.emit(
+            "agent_completed",
+            agent_id=self.agent_id,
+            task_id=request.task_id,
+            payload={
+                "output_preview": output[:200],
+                "artifact_keys": sorted(artifacts.keys()),
+            },
+        )
+        return AgentResponse(
+            agent_id=self.agent_id,
+            output=output,
+            artifacts=artifacts,
+            events=[event],
+        )
+
+    def _run(self, request: AgentRequest) -> str:
+        output, _ = self._run_with_artifacts(request)
+        return output
+
+    def _run_with_artifacts(self, request: AgentRequest) -> tuple[str, dict[str, Any]]:
+        root_dir = Path(settings.rss_digest_root).resolve()
+        data_root = Path(settings.rss_digest_data_root).resolve()
+        cleanup_stats = cleanup_rss_artifacts()
+        print(f"[rss_digest] start {datetime.now().isoformat(timespec='seconds')} input={request.input[:80]}")
+
+        if not root_dir.exists():
+            message = f"rss_digest 项目路径不存在,无法运行资讯员:{root_dir}"
+            print(f"[rss_digest][error] {message}")
+            return message, {
+                "ready": False,
+                "rss_digest_root": str(root_dir),
+                "rss_digest_data_root": str(data_root),
+                "cleanup": cleanup_stats,
+            }
+
+        if request.context.get("mode") == "group_chat":
+            digest_path = self._latest_digest_path(data_root)
+            print("[rss_digest] skipped: group_chat guard")
+            if digest_path:
+                return (
+                    f"资讯员已就绪。最新 RSS 简报:{digest_path}",
+                    {
+                        "skipped": True,
+                        "reason": "batch_guard",
+                        "digest_path": str(digest_path),
+                        "rss_digest_data_root": str(data_root),
+                        "cleanup": cleanup_stats,
+                    },
+                )
+            return (
+                "资讯员是较长耗时流程。请单独使用 @rss_digest 生成或更新 RSS 中文简报。",
+                {"skipped": True, "reason": "batch_guard", "cleanup": cleanup_stats},
+            )
+
+        if request.context.get("dry_run"):
+            print("[rss_digest] dry_run ok")
+            return (
+                "资讯员已接入 rss_digest,真实运行会拉取 RSS、生成中文摘要并输出 HTML 简报。",
+                {
+                    "ready": True,
+                    "rss_digest_root": str(root_dir),
+                    "rss_digest_data_root": str(data_root),
+                    "cleanup": cleanup_stats,
+                },
+            )
+
+        modules = self._load_rss_modules(root_dir)
+        force_refresh = bool(request.context.get("force_refresh")) or self._is_force_refresh(request.input)
+        today_digest_path = self._today_digest_path(data_root)
+        if today_digest_path and not force_refresh:
+            print("[rss_digest] skipped: today digest exists")
+            recent_articles = self._recent_articles(root_dir, data_root, modules, limit=8)
+            digest_url = self._digest_url(today_digest_path)
+            run_stats = {
+                "skipped": True,
+                "reason": "today_digest_exists",
+                "digest_article_count": len(recent_articles),
+                "llm_enabled": True,
+            }
+            return self._format_output(today_digest_path, digest_url, recent_articles, run_stats), {
+                "skipped": True,
+                "reason": "today_digest_exists",
+                "rss_digest_root": str(root_dir),
+                "rss_digest_data_root": str(data_root),
+                "digest_path": str(today_digest_path),
+                "digest_url": digest_url,
+                "recent_articles": recent_articles,
+                "run_stats": run_stats,
+                "cleanup": cleanup_stats,
+            }
+
+        stdout_buffer = io.StringIO()
+        print("[rss_digest] running pipeline")
+        started = perf_counter()
+        with redirect_stdout(stdout_buffer):
+            run_stats = modules["pipeline"].run_pipeline(root_dir, data_root)
+        run_stats["adapter_total_seconds"] = round(perf_counter() - started, 3)
+        print(
+            "[rss_digest] complete "
+            f"discovered={run_stats.get('discovered', 0)} "
+            f"extracted={run_stats.get('extracted', 0)} "
+            f"summarized={run_stats.get('summarized', 0)} "
+            f"digest_articles={run_stats.get('digest_article_count', 0)} "
+            f"seconds={run_stats.get('adapter_total_seconds')}"
+        )
+
+        digest_path = self._latest_digest_path(data_root)
+        digest_url = self._digest_url(digest_path)
+        recent_articles = self._recent_articles(root_dir, data_root, modules, limit=8)
+
+        output = self._format_output(digest_path, digest_url, recent_articles, run_stats)
+        artifacts = {
+            "rss_digest_root": str(root_dir),
+            "rss_digest_data_root": str(data_root),
+            "digest_path": str(digest_path) if digest_path else None,
+            "digest_url": digest_url,
+            "recent_articles": recent_articles,
+            "run_stats": run_stats,
+            "stdout": stdout_buffer.getvalue().strip(),
+            "cleanup": cleanup_stats,
+        }
+        return output, artifacts
+
+    @staticmethod
+    def _load_rss_modules(root_dir: Path) -> dict[str, Any]:
+        src_dir = root_dir / "src"
+        src_text = str(src_dir)
+        if src_text not in sys.path:
+            sys.path.insert(0, src_text)
+
+        return {
+            "pipeline": importlib.import_module("rss_digest.pipeline"),
+            "config": importlib.import_module("rss_digest.config"),
+            "db": importlib.import_module("rss_digest.db"),
+        }
+
+    @staticmethod
+    def _latest_digest_path(data_root: Path) -> Path | None:
+        digest_dir = data_root / "runs" / "digests"
+        files = sorted(digest_dir.glob("digest_*.html"), key=lambda path: path.stat().st_mtime, reverse=True)
+        return files[0] if files else None
+
+    @staticmethod
+    def _today_digest_path(data_root: Path) -> Path | None:
+        digest_path = data_root / "runs" / "digests" / f"digest_{datetime.now().strftime('%Y-%m-%d')}.html"
+        return digest_path if digest_path.exists() else None
+
+    @staticmethod
+    def _is_force_refresh(text: str) -> bool:
+        normalized = text.lower()
+        return any(token in normalized for token in ("强制", "重新生成", "刷新", "force", "refresh"))
+
+    @staticmethod
+    def _digest_url(digest_path: Path | None) -> str | None:
+        if not digest_path:
+            return None
+        return f"/rss-digests/{digest_path.name}"
+
+    @staticmethod
+    def _recent_articles(root_dir: Path, data_root: Path, modules: dict[str, Any], limit: int) -> list[dict[str, Any]]:
+        cfg = modules["config"].build_config(root_dir, data_root)
+        conn = modules["db"].connect(cfg.db_path)
+        modules["db"].init_db(conn)
+        rows = modules["db"].get_recent_articles(conn, limit=limit)
+        return [
+            {
+                "title": row.get("title", ""),
+                "source_name": row.get("source_name", ""),
+                "category": row.get("category", ""),
+                "published_at": row.get("published_at", ""),
+                "link": row.get("link", ""),
+                "article_score": row.get("article_score"),
+                "one_line": row.get("one_line"),
+                "worth_reading": row.get("worth_reading"),
+            }
+            for row in rows
+        ]
+
+    @staticmethod
+    def _format_output(
+        digest_path: Path | None,
+        digest_url: str | None,
+        articles: list[dict[str, Any]],
+        run_stats: dict[str, Any] | None,
+    ) -> str:
+        lines = ["资讯员已完成 RSS 更新和中文摘要生成。"]
+        if run_stats:
+            lines.append(
+                "本轮统计:"
+                f"RSS新增 {run_stats.get('discovered', 0)},"
+                f"正文抽取 {run_stats.get('extracted', 0)},"
+                f"LLM摘要 {run_stats.get('summarized', 0)},"
+                f"本次简报文章 {run_stats.get('digest_article_count', 0)},"
+                f"LLM启用 {run_stats.get('llm_enabled', False)}。"
+            )
+            if run_stats.get("no_new_articles"):
+                lines.append("提示:本次没有新的未读文章进入简报,已避免重复展示今天看过的内容。")
+            if run_stats.get("llm_enabled") and run_stats.get("summarized", 0) == 0:
+                lines.append("提示:LLM 已配置,但本轮没有成功摘要新文章,可查看任务 artifacts 中的 stdout 和 run_stats。")
+            if not run_stats.get("llm_enabled"):
+                lines.append("提示:LLM 未启用,请检查 .env 中的 LLM_MODEL_ID、LLM_API_KEY、LLM_BASE_URL。")
+        if digest_path:
+            lines.append(f"最新 HTML 简报:{digest_path}")
+        if digest_url:
+            lines.append(f"点击打开:{digest_url}")
+        if articles:
+            lines.append("")
+            lines.append("最新文章:")
+            for index, article in enumerate(articles[:5], start=1):
+                title = article.get("title") or "未命名文章"
+                source = article.get("source_name") or "未知来源"
+                score = article.get("article_score")
+                score_text = f",评分 {score}" if score is not None else ""
+                lines.append(f"{index}. {title},{source}{score_text}")
+                one_line = article.get("one_line")
+                if one_line:
+                    lines.append(f"   {one_line}")
+        return "\n".join(lines)

+ 31 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/base.py

@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from backend.events import event_logger
+from backend.memory.base import memory_store
+from backend.models import AgentProfile, AgentRequest, AgentResponse
+
+
+class BaseAgent:
+    """Common platform contract for all agents."""
+
+    def __init__(self, profile: AgentProfile) -> None:
+        self.profile = profile
+
+    @property
+    def agent_id(self) -> str:
+        return self.profile.agent_id
+
+    def run(self, request: AgentRequest) -> AgentResponse:
+        event_logger.emit("agent_started", agent_id=self.agent_id, task_id=request.task_id)
+        output = self._run(request)
+        memory_store.add(self.agent_id, f"input={request.input} output={output}")
+        event = event_logger.emit(
+            "agent_completed",
+            agent_id=self.agent_id,
+            task_id=request.task_id,
+            payload={"output_preview": output[:200]},
+        )
+        return AgentResponse(agent_id=self.agent_id, output=output, events=[event])
+
+    def _run(self, request: AgentRequest) -> str:
+        raise NotImplementedError

+ 26 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/profiles.py

@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from backend.models import AgentKind, AgentProfile
+
+
+def default_profiles() -> list[AgentProfile]:
+    return [
+        AgentProfile(
+            agent_id="deep_research",
+            name="搜索员",
+            kind=AgentKind.research,
+            description="自动搜索互联网结果并生成研究报告。",
+            system_prompt="Coordinate research tasks and produce a report.",
+            tools=["web_search", "notes", "summarizer"],
+            enabled=True,
+        ),
+        AgentProfile(
+            agent_id="rss_digest",
+            name="资讯员",
+            kind=AgentKind.research,
+            description="拉取 RSS 源并生成中文资讯简报。",
+            system_prompt="Collect RSS updates, summarize them in Chinese, and return a daily digest.",
+            tools=["rss", "article_extractor", "translator", "html_digest"],
+            enabled=True,
+        ),
+    ]

+ 34 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/registry.py

@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from typing import Dict, Iterable, List
+
+from backend.agents.base import BaseAgent
+from backend.agents.profiles import default_profiles
+from backend.agents.adapters.deep_research import DeepResearchAdapter
+from backend.agents.adapters.rss_digest import RSSDigestAdapter
+from backend.models import AgentProfile
+
+
+class AgentRegistry:
+    def __init__(self) -> None:
+        self._agents: Dict[str, BaseAgent] = {}
+
+    def register(self, agent: BaseAgent) -> None:
+        self._agents[agent.agent_id] = agent
+
+    def get(self, agent_id: str) -> BaseAgent:
+        return self._agents[agent_id]
+
+    def list_profiles(self) -> List[AgentProfile]:
+        return [agent.profile for agent in self._agents.values()]
+
+    def ids(self) -> Iterable[str]:
+        return self._agents.keys()
+
+
+def build_default_registry() -> AgentRegistry:
+    registry = AgentRegistry()
+    profiles = {profile.agent_id: profile for profile in default_profiles()}
+    registry.register(DeepResearchAdapter(profiles["deep_research"]))
+    registry.register(RSSDigestAdapter(profiles["rss_digest"]))
+    return registry

+ 103 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/config.py

@@ -0,0 +1,103 @@
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+
+try:
+    from dotenv import load_dotenv
+except ImportError:  # pragma: no cover - optional dependency
+    load_dotenv = None
+
+
+ROOT_DIR = Path(__file__).resolve().parents[1]
+ENV_FILE = ROOT_DIR / ".env"
+if load_dotenv and ENV_FILE.exists():
+    load_dotenv(ENV_FILE, override=False)
+
+
+for proxy_key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"):
+    proxy_value = os.getenv(proxy_key, "")
+    if proxy_value in {"http://127.0.0.1:9", "https://127.0.0.1:9"}:
+        os.environ.pop(proxy_key, None)
+
+
+def _bool_env(name: str, default: bool) -> bool:
+    value = os.getenv(name)
+    if value is None:
+        return default
+    return value.strip().lower() in {"1", "true", "yes", "y", "on"}
+
+
+def _int_env(name: str, default: int) -> int:
+    value = os.getenv(name)
+    if value is None or not value.strip():
+        return default
+    return int(value)
+
+
+def _path_env(name: str, default: Path) -> str:
+    value = os.getenv(name)
+    path = Path(value) if value else default
+    if not path.is_absolute():
+        path = ROOT_DIR / path
+    return str(path.resolve())
+
+
+def _chapter14_backend_default() -> Path:
+    chapter14_root = ROOT_DIR.parents[1] / "chapter14"
+    candidates = [
+        ROOT_DIR / "agents" / "deep_research" / "src",
+        chapter14_root / "helloagents-deepresearch" / "backend" / "src",
+        chapter14_root / "helloagents-deepresearch-fixed" / "backend" / "src",
+    ]
+    for candidate in candidates:
+        if candidate.exists():
+            return candidate
+    return candidates[0]
+
+
+@dataclass(frozen=True)
+class Settings:
+    app_name: str = os.getenv("APP_NAME", "Agent Platform Base")
+    app_host: str = os.getenv("APP_HOST", "127.0.0.1")
+    app_port: int = int(os.getenv("APP_PORT", "8016"))
+    chapter14_backend_path: str = _path_env("CHAPTER14_BACKEND_PATH", _chapter14_backend_default())
+
+    llm_provider: str | None = os.getenv("LLM_PROVIDER") or None
+    llm_model_id: str | None = os.getenv("LLM_MODEL_ID") or None
+    llm_api_key: str | None = os.getenv("LLM_API_KEY") or None
+    llm_base_url: str | None = os.getenv("LLM_BASE_URL") or None
+    llm_timeout: str | None = os.getenv("LLM_TIMEOUT") or None
+
+    search_api: str | None = os.getenv("SEARCH_API") or None
+    max_web_research_loops: str | None = os.getenv("MAX_WEB_RESEARCH_LOOPS") or None
+    fetch_full_page: str | None = os.getenv("FETCH_FULL_PAGE") or None
+    enable_notes: str | None = os.getenv("ENABLE_NOTES") or None
+    persist_runs: str | None = os.getenv("PERSIST_RUNS") or None
+    cleanup_intermediate_files: str | None = os.getenv("CLEANUP_INTERMEDIATE_FILES") or None
+    notes_workspace: str = os.getenv(
+        "NOTES_WORKSPACE",
+        str((ROOT_DIR / "data" / "deep_research" / "notes").resolve()),
+    )
+    run_workspace: str = os.getenv(
+        "RUN_WORKSPACE",
+        str((ROOT_DIR / "data" / "deep_research" / "runs").resolve()),
+    )
+    rss_digest_root: str = os.getenv(
+        "RSS_DIGEST_ROOT",
+        str((ROOT_DIR / "agents" / "rss_digest").resolve()),
+    )
+    rss_digest_data_root: str = os.getenv(
+        "RSS_DIGEST_DATA_ROOT",
+        str((ROOT_DIR / "data" / "rss_digest").resolve()),
+    )
+    maintenance_cleanup_enabled: bool = _bool_env("MAINTENANCE_CLEANUP_ENABLED", True)
+    maintenance_cleanup_interval_hours: int = _int_env("MAINTENANCE_CLEANUP_INTERVAL_HOURS", 6)
+    research_run_retention_days: int = _int_env("RESEARCH_RUN_RETENTION_DAYS", 7)
+    rss_digest_retention_days: int = _int_env("RSS_DIGEST_RETENTION_DAYS", 7)
+    rss_cache_retention_days: int = _int_env("RSS_CACHE_RETENTION_DAYS", 7)
+
+
+settings = Settings()

+ 44 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/events.py

@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from datetime import datetime
+from threading import Lock
+from typing import Any, Dict, List, Optional
+from uuid import uuid4
+
+
+class EventLogger:
+    """In-memory structured event logger for development."""
+
+    def __init__(self) -> None:
+        self._events: List[Dict[str, Any]] = []
+        self._lock = Lock()
+
+    def emit(
+        self,
+        event_type: str,
+        *,
+        agent_id: Optional[str] = None,
+        task_id: Optional[str] = None,
+        payload: Optional[Dict[str, Any]] = None,
+    ) -> Dict[str, Any]:
+        event = {
+            "event_id": uuid4().hex,
+            "type": event_type,
+            "agent_id": agent_id,
+            "task_id": task_id,
+            "payload": payload or {},
+            "timestamp": datetime.now().isoformat(),
+        }
+        with self._lock:
+            self._events.append(event)
+        return event
+
+    def list_events(self, *, task_id: Optional[str] = None, limit: int = 100) -> List[Dict[str, Any]]:
+        with self._lock:
+            events = list(self._events)
+        if task_id:
+            events = [event for event in events if event.get("task_id") == task_id]
+        return events[-limit:]
+
+
+event_logger = EventLogger()

+ 117 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/main.py

@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+import logging
+import os
+from pathlib import Path
+
+from fastapi import FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import RedirectResponse
+from fastapi.staticfiles import StaticFiles
+
+from backend.agents.registry import build_default_registry
+from backend.config import settings
+from backend.events import event_logger
+from backend.models import AgentRequest, AgentResponse, BatchRunRequest, TaskCreateRequest, TaskRecord
+from backend.tasks.batch import BatchRunner
+from backend.tasks.manager import task_manager
+from backend.tasks.runner import TaskRunner
+
+
+if os.getenv("APP_ACCESS_LOG", "false").strip().lower() not in {"1", "true", "yes", "on"}:
+    logging.getLogger("uvicorn.access").disabled = True
+    logging.getLogger("uvicorn.access").setLevel(logging.CRITICAL + 1)
+
+for noisy_logger in ("agent", "services", "services.planner", "services.tool_events"):
+    logging.getLogger(noisy_logger).setLevel(logging.WARNING)
+
+app = FastAPI(title=settings.app_name, version="0.1.0")
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+registry = build_default_registry()
+task_runner = TaskRunner(registry, task_manager)
+batch_runner = BatchRunner(registry)
+
+FRONTEND_DIR = Path(__file__).resolve().parents[1] / "frontend"
+if FRONTEND_DIR.exists():
+    app.mount("/app", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
+
+RSS_DIGEST_DIR = Path(settings.rss_digest_data_root).resolve() / "runs" / "digests"
+if RSS_DIGEST_DIR.exists():
+    app.mount("/rss-digests", StaticFiles(directory=RSS_DIGEST_DIR, html=True), name="rss_digests")
+
+
+@app.get("/", include_in_schema=False)
+def index() -> RedirectResponse:
+    return RedirectResponse(url="/app/")
+
+
+@app.get("/health")
+def health() -> dict:
+    return {"status": "healthy", "service": settings.app_name}
+
+
+@app.get("/agents")
+def list_agents() -> dict:
+    profiles = registry.list_profiles()
+    return {"agents": profiles, "total": len(profiles)}
+
+
+@app.post("/agents/{agent_id}/run", response_model=AgentResponse)
+def run_agent(agent_id: str, request: AgentRequest) -> AgentResponse:
+    try:
+        return registry.get(agent_id).run(request)
+    except KeyError:
+        raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
+
+
+@app.post("/tasks", response_model=TaskRecord)
+def create_task(request: TaskCreateRequest) -> TaskRecord:
+    if request.agent_id not in set(registry.ids()):
+        raise HTTPException(status_code=404, detail=f"Agent '{request.agent_id}' not found")
+    return task_manager.create(request)
+
+
+@app.get("/tasks")
+def list_tasks() -> dict:
+    tasks = task_manager.list()
+    return {"tasks": tasks, "total": len(tasks)}
+
+
+@app.get("/tasks/{task_id}", response_model=TaskRecord)
+def get_task(task_id: str) -> TaskRecord:
+    try:
+        return task_manager.get(task_id)
+    except KeyError:
+        raise HTTPException(status_code=404, detail=f"Task '{task_id}' not found")
+
+
+@app.post("/tasks/{task_id}/run", response_model=TaskRecord)
+def run_task(task_id: str, background: bool = True) -> TaskRecord:
+    try:
+        task_manager.get(task_id)
+    except KeyError:
+        raise HTTPException(status_code=404, detail=f"Task '{task_id}' not found")
+    if background:
+        return task_runner.start_background(task_id)
+    return task_runner.run(task_id)
+
+
+@app.post("/batch/run")
+def run_batch(request: BatchRunRequest) -> dict:
+    try:
+        return {"responses": batch_runner.run(request.requests)}
+    except KeyError as exc:
+        raise HTTPException(status_code=404, detail=f"Agent '{exc.args[0]}' not found")
+
+
+@app.get("/events")
+def list_events(task_id: str | None = None, limit: int = 100) -> dict:
+    return {"events": event_logger.list_events(task_id=task_id, limit=limit)}

+ 149 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/maintenance.py

@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+import shutil
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any
+
+from backend.config import ROOT_DIR, settings
+
+
+_last_cleanup: dict[str, datetime] = {}
+
+
+def cleanup_deep_research_artifacts(*, force: bool = False) -> dict[str, Any]:
+    """Remove old deep research run artifacts.
+
+    This intentionally does not delete notes. Notes are indexed memory artifacts,
+    while runs are reproducible per-execution files that can grow quickly.
+    """
+
+    if not _should_run("deep_research", force=force):
+        return {"skipped": True, "reason": "interval"}
+
+    run_root = _resolve_workspace(settings.run_workspace)
+    stats = _cleanup_children(
+        run_root,
+        retention_days=settings.research_run_retention_days,
+        file_patterns=["*"],
+        delete_dirs=True,
+    )
+    stats.update(
+        {
+            "skipped": False,
+            "target": str(run_root),
+            "retention_days": settings.research_run_retention_days,
+        }
+    )
+    return stats
+
+
+def cleanup_rss_artifacts(*, force: bool = False) -> dict[str, Any]:
+    """Remove old RSS generated files while keeping article state intact."""
+
+    if not _should_run("rss_digest", force=force):
+        return {"skipped": True, "reason": "interval"}
+
+    data_root = Path(settings.rss_digest_data_root).resolve() / "runs"
+    totals = {"deleted_files": 0, "deleted_dirs": 0, "deleted_bytes": 0}
+
+    for relative, retention_days, patterns in (
+        ("digests", settings.rss_digest_retention_days, ["digest_*.html"]),
+        ("raw", settings.rss_cache_retention_days, ["*"]),
+        ("extracted", settings.rss_cache_retention_days, ["*"]),
+        ("translated", settings.rss_cache_retention_days, ["*"]),
+    ):
+        stats = _cleanup_children(
+            data_root / relative,
+            retention_days=retention_days,
+            file_patterns=patterns,
+            delete_dirs=False,
+        )
+        for key in totals:
+            totals[key] += stats[key]
+
+    totals.update(
+        {
+            "skipped": False,
+            "target": str(data_root),
+            "digest_retention_days": settings.rss_digest_retention_days,
+            "cache_retention_days": settings.rss_cache_retention_days,
+        }
+    )
+    return totals
+
+
+def _should_run(name: str, *, force: bool) -> bool:
+    if not settings.maintenance_cleanup_enabled:
+        return False
+
+    now = datetime.now()
+    last_run = _last_cleanup.get(name)
+    interval = timedelta(hours=max(settings.maintenance_cleanup_interval_hours, 1))
+    if not force and last_run and now - last_run < interval:
+        return False
+
+    _last_cleanup[name] = now
+    return True
+
+
+def _resolve_workspace(value: str) -> Path:
+    path = Path(value)
+    if not path.is_absolute():
+        path = ROOT_DIR / path
+    path.mkdir(parents=True, exist_ok=True)
+    return path.resolve()
+
+
+def _cleanup_children(
+    root: Path,
+    *,
+    retention_days: int,
+    file_patterns: list[str],
+    delete_dirs: bool,
+) -> dict[str, int]:
+    stats = {"deleted_files": 0, "deleted_dirs": 0, "deleted_bytes": 0}
+    if retention_days <= 0 or not root.exists():
+        return stats
+
+    root = root.resolve()
+    cutoff = datetime.now() - timedelta(days=retention_days)
+
+    if delete_dirs:
+        for child in root.iterdir():
+            if not child.exists() or not _is_child_of(child, root):
+                continue
+            if datetime.fromtimestamp(child.stat().st_mtime) >= cutoff:
+                continue
+            if child.is_dir():
+                stats["deleted_bytes"] += _directory_size(child)
+                shutil.rmtree(child)
+                stats["deleted_dirs"] += 1
+            elif child.is_file():
+                stats["deleted_bytes"] += child.stat().st_size
+                child.unlink()
+                stats["deleted_files"] += 1
+        return stats
+
+    for pattern in file_patterns:
+        for path in root.glob(pattern):
+            if not path.is_file() or not _is_child_of(path, root):
+                continue
+            if datetime.fromtimestamp(path.stat().st_mtime) >= cutoff:
+                continue
+            stats["deleted_bytes"] += path.stat().st_size
+            path.unlink()
+            stats["deleted_files"] += 1
+    return stats
+
+
+def _is_child_of(path: Path, root: Path) -> bool:
+    try:
+        path.resolve().relative_to(root.resolve())
+        return True
+    except ValueError:
+        return False
+
+
+def _directory_size(path: Path) -> int:
+    return sum(item.stat().st_size for item in path.rglob("*") if item.is_file())

+ 74 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/models.py

@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from datetime import datetime
+from enum import Enum
+from typing import Any, Dict, List, Optional
+from uuid import uuid4
+
+from pydantic import BaseModel, Field
+
+
+class AgentKind(str, Enum):
+    chat = "chat"
+    planner = "planner"
+    research = "research"
+    tool = "tool"
+
+
+class AgentProfile(BaseModel):
+    agent_id: str
+    name: str
+    kind: AgentKind
+    description: str
+    system_prompt: str = ""
+    tools: List[str] = Field(default_factory=list)
+    memory_policy: str = "session"
+    enabled: bool = True
+
+
+class AgentRequest(BaseModel):
+    input: str = Field(..., min_length=1)
+    context: Dict[str, Any] = Field(default_factory=dict)
+    task_id: Optional[str] = None
+
+
+class AgentResponse(BaseModel):
+    agent_id: str
+    output: str
+    artifacts: Dict[str, Any] = Field(default_factory=dict)
+    events: List[Dict[str, Any]] = Field(default_factory=list)
+
+
+class TaskStatus(str, Enum):
+    pending = "pending"
+    running = "running"
+    completed = "completed"
+    failed = "failed"
+
+
+class TaskCreateRequest(BaseModel):
+    title: str = Field(..., min_length=1)
+    input: str = Field(..., min_length=1)
+    agent_id: str = "general_chat"
+    metadata: Dict[str, Any] = Field(default_factory=dict)
+
+
+class TaskRecord(BaseModel):
+    task_id: str = Field(default_factory=lambda: uuid4().hex)
+    title: str
+    input: str
+    agent_id: str
+    status: TaskStatus = TaskStatus.pending
+    output: Optional[str] = None
+    artifacts: Dict[str, Any] = Field(default_factory=dict)
+    metadata: Dict[str, Any] = Field(default_factory=dict)
+    error: Optional[str] = None
+    created_at: datetime = Field(default_factory=datetime.now)
+    updated_at: datetime = Field(default_factory=datetime.now)
+
+
+class BatchRunRequest(BaseModel):
+    requests: Dict[str, AgentRequest] = Field(
+        ...,
+        description="Mapping from agent_id to request payload.",
+    )

+ 5 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/tasks/__init__.py

@@ -0,0 +1,5 @@
+from .batch import BatchRunner
+from .manager import TaskManager
+from .runner import TaskRunner
+
+__all__ = ["BatchRunner", "TaskManager", "TaskRunner"]

+ 17 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/tasks/batch.py

@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+from typing import Dict
+
+from backend.agents.registry import AgentRegistry
+from backend.models import AgentRequest, AgentResponse
+
+
+class BatchRunner:
+    def __init__(self, registry: AgentRegistry) -> None:
+        self.registry = registry
+
+    def run(self, requests: Dict[str, AgentRequest]) -> Dict[str, AgentResponse]:
+        responses: Dict[str, AgentResponse] = {}
+        for agent_id, request in requests.items():
+            responses[agent_id] = self.registry.get(agent_id).run(request)
+        return responses

+ 60 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/tasks/manager.py

@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+from datetime import datetime
+from threading import Lock
+from typing import Dict, List
+
+from backend.models import TaskCreateRequest, TaskRecord, TaskStatus
+
+
+class TaskManager:
+    def __init__(self) -> None:
+        self._tasks: Dict[str, TaskRecord] = {}
+        self._lock = Lock()
+
+    def create(self, request: TaskCreateRequest) -> TaskRecord:
+        task = TaskRecord(
+            title=request.title,
+            input=request.input,
+            agent_id=request.agent_id,
+            metadata=request.metadata,
+        )
+        with self._lock:
+            self._tasks[task.task_id] = task
+        return task
+
+    def get(self, task_id: str) -> TaskRecord:
+        with self._lock:
+            return self._tasks[task_id]
+
+    def list(self) -> List[TaskRecord]:
+        with self._lock:
+            return list(self._tasks.values())
+
+    def update_status(self, task_id: str, status: TaskStatus, *, error: str | None = None) -> TaskRecord:
+        with self._lock:
+            task = self._tasks[task_id]
+            task.status = status
+            task.error = error
+            task.updated_at = datetime.now()
+            return task
+
+    def complete(self, task_id: str, *, output: str, artifacts: dict) -> TaskRecord:
+        with self._lock:
+            task = self._tasks[task_id]
+            task.output = output
+            task.artifacts = artifacts
+            task.status = TaskStatus.completed
+            task.updated_at = datetime.now()
+            return task
+
+    def fail(self, task_id: str, error: str) -> TaskRecord:
+        with self._lock:
+            task = self._tasks[task_id]
+            task.status = TaskStatus.failed
+            task.error = error
+            task.updated_at = datetime.now()
+            return task
+
+
+task_manager = TaskManager()

+ 56 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/tasks/runner.py

@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from threading import Thread
+from time import perf_counter
+
+from backend.agents.registry import AgentRegistry
+from backend.events import event_logger
+from backend.models import AgentRequest, TaskRecord, TaskStatus
+from backend.tasks.manager import TaskManager
+
+
+class TaskRunner:
+    def __init__(self, registry: AgentRegistry, manager: TaskManager) -> None:
+        self.registry = registry
+        self.manager = manager
+
+    def run(self, task_id: str) -> TaskRecord:
+        return self._run_now(task_id)
+
+    def start_background(self, task_id: str) -> TaskRecord:
+        task = self.manager.get(task_id)
+        if task.status == TaskStatus.running:
+            return task
+        task = self.manager.update_status(task_id, TaskStatus.running)
+        thread = Thread(target=self._run_now, args=(task_id,), daemon=True)
+        thread.start()
+        return task
+
+    def _run_now(self, task_id: str) -> TaskRecord:
+        task = self.manager.update_status(task_id, TaskStatus.running)
+        event_logger.emit("task_started", agent_id=task.agent_id, task_id=task_id)
+        started = perf_counter()
+        try:
+            agent = self.registry.get(task.agent_id)
+            response = agent.run(AgentRequest(input=task.input, context=task.metadata, task_id=task_id))
+            elapsed = round(perf_counter() - started, 3)
+            artifacts = dict(response.artifacts)
+            artifacts["elapsed_seconds"] = elapsed
+            task = self.manager.complete(task_id, output=response.output, artifacts=artifacts)
+            event_logger.emit(
+                "task_completed",
+                agent_id=task.agent_id,
+                task_id=task_id,
+                payload={"elapsed_seconds": elapsed},
+            )
+            return task
+        except Exception as exc:
+            elapsed = round(perf_counter() - started, 3)
+            task = self.manager.fail(task_id, str(exc))
+            event_logger.emit(
+                "task_failed",
+                agent_id=task.agent_id,
+                task_id=task_id,
+                payload={"error": str(exc), "elapsed_seconds": elapsed},
+            )
+            return task

+ 461 - 0
Co-creation-projects/huailishang-AgentPlatformBase/frontend/app.js

@@ -0,0 +1,461 @@
+const state = {
+  agents: [],
+  lastTask: null,
+  activeTaskId: null,
+  mentionOptions: [],
+  activeMentionIndex: 0,
+};
+
+const els = {
+  agentList: document.getElementById("agentList"),
+  chatForm: document.getElementById("chatForm"),
+  messageInput: document.getElementById("messageInput"),
+  mentionMenu: document.getElementById("mentionMenu"),
+  messages: document.getElementById("messages"),
+  statusText: document.getElementById("statusText"),
+  refreshButton: document.getElementById("refreshButton"),
+  taskView: document.getElementById("taskView"),
+  eventList: document.getElementById("eventList"),
+};
+
+async function api(path, options = {}) {
+  const response = await fetch(path, {
+    headers: { "Content-Type": "application/json", ...(options.headers || {}) },
+    ...options,
+  });
+  if (!response.ok) {
+    const text = await response.text();
+    throw new Error(text || `HTTP ${response.status}`);
+  }
+  return response.json();
+}
+
+function nowText() {
+  return new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
+}
+
+function escapeHtml(value) {
+  return String(value)
+    .replaceAll("&", "&amp;")
+    .replaceAll("<", "&lt;")
+    .replaceAll(">", "&gt;")
+    .replaceAll('"', "&quot;")
+    .replaceAll("'", "&#039;");
+}
+
+function linkify(text) {
+  const escaped = escapeHtml(text);
+  return escaped.replace(/(https?:\/\/[^\s]+|\/rss-digests\/[^\s]+)/g, (url) => {
+    const href = url.startsWith("/") ? url : url;
+    return `<a href="${href}" target="_blank" rel="noreferrer">${url}</a>`;
+  });
+}
+
+function renderInlineMarkdown(text) {
+  let html = linkify(text);
+  html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
+  html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
+  html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
+  return html;
+}
+
+function renderMarkdown(markdown) {
+  const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
+  const blocks = [];
+  let paragraph = [];
+  let list = null;
+  let code = null;
+
+  function flushParagraph() {
+    if (!paragraph.length) return;
+    blocks.push(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`);
+    paragraph = [];
+  }
+
+  function flushList() {
+    if (!list) return;
+    const tag = list.type === "ol" ? "ol" : "ul";
+    blocks.push(`<${tag}>${list.items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</${tag}>`);
+    list = null;
+  }
+
+  function flushCode() {
+    if (code === null) return;
+    blocks.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
+    code = null;
+  }
+
+  for (const line of lines) {
+    if (line.trim().startsWith("```")) {
+      if (code === null) {
+        flushParagraph();
+        flushList();
+        code = [];
+      } else {
+        flushCode();
+      }
+      continue;
+    }
+
+    if (code !== null) {
+      code.push(line);
+      continue;
+    }
+
+    const trimmed = line.trim();
+    if (!trimmed) {
+      flushParagraph();
+      flushList();
+      continue;
+    }
+
+    const heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
+    if (heading) {
+      flushParagraph();
+      flushList();
+      const level = heading[1].length;
+      blocks.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
+      continue;
+    }
+
+    const unordered = trimmed.match(/^[-*]\s+(.+)$/);
+    if (unordered) {
+      flushParagraph();
+      if (!list || list.type !== "ul") {
+        flushList();
+        list = { type: "ul", items: [] };
+      }
+      list.items.push(unordered[1]);
+      continue;
+    }
+
+    const ordered = trimmed.match(/^\d+\.\s+(.+)$/);
+    if (ordered) {
+      flushParagraph();
+      if (!list || list.type !== "ol") {
+        flushList();
+        list = { type: "ol", items: [] };
+      }
+      list.items.push(ordered[1]);
+      continue;
+    }
+
+    if (trimmed.startsWith("> ")) {
+      flushParagraph();
+      flushList();
+      blocks.push(`<blockquote>${renderInlineMarkdown(trimmed.slice(2))}</blockquote>`);
+      continue;
+    }
+
+    flushList();
+    paragraph.push(trimmed);
+  }
+
+  flushCode();
+  flushParagraph();
+  flushList();
+  return blocks.join("");
+}
+
+function appendMessage(kind, author, body) {
+  const node = document.createElement("article");
+  node.className = `message ${kind}`;
+  node.innerHTML = `
+    <div class="message-head">
+      <strong>${escapeHtml(author)}</strong>
+      <span>${nowText()}</span>
+    </div>
+    <div class="message-body">${renderMarkdown(body)}</div>
+  `;
+  els.messages.appendChild(node);
+  els.messages.scrollTop = els.messages.scrollHeight;
+}
+
+function insertMention(agentId) {
+  const mention = `@${agentId} `;
+  const current = els.messageInput.value.trimStart();
+  const withoutOldMention = current.replace(/^@[a-zA-Z0-9_\-]+\s*/, "");
+  els.messageInput.value = mention + withoutOldMention;
+  hideMentionMenu();
+  els.messageInput.focus();
+}
+
+function mentionChoices() {
+  return state.agents.map((agent) => ({
+    ...agent,
+    mention_id: agent.agent_id,
+  }));
+}
+
+function mentionQuery() {
+  const value = els.messageInput.value;
+  const cursor = els.messageInput.selectionStart || 0;
+  const beforeCursor = value.slice(0, cursor);
+  const match = beforeCursor.match(/(^|\s)@([a-zA-Z0-9_\-]*)$/);
+  return match ? match[2].toLowerCase() : null;
+}
+
+function hideMentionMenu() {
+  els.mentionMenu.hidden = true;
+  els.mentionMenu.innerHTML = "";
+  state.mentionOptions = [];
+  state.activeMentionIndex = 0;
+}
+
+function chooseMention(option) {
+  const value = els.messageInput.value;
+  const cursor = els.messageInput.selectionStart || 0;
+  const beforeCursor = value.slice(0, cursor);
+  const afterCursor = value.slice(cursor);
+  const replacement = `@${option.agent_id} `;
+  const replacedBefore = beforeCursor.replace(/(^|\s)@[a-zA-Z0-9_\-]*$/, (prefix) => {
+    const leadingSpace = prefix.startsWith(" ") ? " " : "";
+    return leadingSpace + replacement;
+  });
+  els.messageInput.value = replacedBefore + afterCursor.trimStart();
+  hideMentionMenu();
+  els.messageInput.focus();
+}
+
+function renderMentionMenu() {
+  const query = mentionQuery();
+  if (query === null) {
+    hideMentionMenu();
+    return;
+  }
+
+  state.mentionOptions = mentionChoices().filter((option) => {
+    const haystack = `${option.agent_id} ${option.name}`.toLowerCase();
+    return haystack.includes(query);
+  });
+  state.activeMentionIndex = Math.min(state.activeMentionIndex, Math.max(state.mentionOptions.length - 1, 0));
+
+  if (!state.mentionOptions.length) {
+    hideMentionMenu();
+    return;
+  }
+
+  els.mentionMenu.innerHTML = "";
+  for (const [index, option] of state.mentionOptions.entries()) {
+    const item = document.createElement("button");
+    item.type = "button";
+    item.className = `mention-option${index === state.activeMentionIndex ? " active" : ""}`;
+    item.innerHTML = `
+      <strong>@${escapeHtml(option.agent_id)} · ${escapeHtml(option.name)}</strong>
+      <span>${escapeHtml(option.description || "")}</span>
+    `;
+    item.addEventListener("mousedown", (event) => {
+      event.preventDefault();
+      chooseMention(option);
+    });
+    els.mentionMenu.appendChild(item);
+  }
+  els.mentionMenu.hidden = false;
+}
+
+function renderAgents() {
+  els.agentList.innerHTML = "";
+
+  for (const agent of state.agents) {
+    const item = document.createElement("button");
+    item.type = "button";
+    item.className = "agent-item";
+    item.innerHTML = `
+      <div class="agent-name">${escapeHtml(agent.name)}</div>
+      <div class="agent-meta">@${escapeHtml(agent.agent_id)} | ${escapeHtml(agent.memory_policy)}</div>
+      <div class="agent-meta">${escapeHtml(agent.description)}</div>
+    `;
+    item.addEventListener("click", () => insertMention(agent.agent_id));
+    els.agentList.appendChild(item);
+  }
+}
+
+function renderTask() {
+  if (!state.lastTask) {
+    els.taskView.textContent = "暂无任务";
+    return;
+  }
+
+  const task = state.lastTask;
+  els.taskView.innerHTML = `
+    <div class="task-card">
+      <strong>${escapeHtml(task.title)}</strong>
+      <div>智能体:${escapeHtml(task.agent_id)}</div>
+      <div>状态:${escapeHtml(task.status)}</div>
+      <div>任务ID:${escapeHtml(task.task_id)}</div>
+    </div>
+  `;
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => window.setTimeout(resolve, ms));
+}
+
+async function waitForTask(taskId) {
+  while (true) {
+    const task = await api(`/tasks/${taskId}`);
+    state.lastTask = task;
+    renderTask();
+
+    if (task.status === "completed" || task.status === "failed") {
+      return task;
+    }
+
+    await refreshEvents();
+    await sleep(1500);
+  }
+}
+
+async function refreshEvents() {
+  const data = await api("/events?limit=20");
+  els.eventList.innerHTML = "";
+
+  if (!data.events.length) {
+    els.eventList.textContent = "暂无事件";
+    return;
+  }
+
+  for (const event of data.events.slice().reverse()) {
+    const item = document.createElement("div");
+    item.className = "event-item";
+    item.innerHTML = `
+      <div class="event-type">${escapeHtml(event.type)}</div>
+      <div>${escapeHtml(event.agent_id || "system")}</div>
+      <div>${escapeHtml(event.timestamp)}</div>
+    `;
+    els.eventList.appendChild(item);
+  }
+}
+
+async function loadAgents() {
+  const data = await api("/agents");
+  state.agents = data.agents;
+  renderAgents();
+  renderMentionMenu();
+  els.statusText.textContent = `已连接 ${data.total} 个智能体`;
+}
+
+function parseTarget(rawText) {
+  const text = rawText.trim();
+  const match = text.match(/^@([a-zA-Z0-9_\-]+)\s*(.*)$/);
+  if (!match) {
+    return { agentId: null, message: text };
+  }
+
+  const mention = match[1];
+  const message = match[2].trim();
+  return { agentId: mention, message };
+}
+
+async function sendMessage(rawText) {
+  const { agentId, message } = parseTarget(rawText);
+  if (!message) {
+    appendMessage("system", "系统", "请输入消息内容。示例:@deep_research 调研一个主题");
+    return;
+  }
+
+  if (!agentId) {
+    appendMessage("system", "系统", "请先用 @ 选择一个智能体,例如:@deep_research 调研一个主题,或 @rss_digest 今日简报。");
+    return;
+  }
+
+  const agent = state.agents.find((item) => item.agent_id === agentId);
+  if (!agent) {
+    appendMessage("system", "系统", `未找到智能体 @${agentId}。请点击左侧智能体插入正确的 @ 标记。`);
+    return;
+  }
+
+  appendMessage("user", "你", `@${agentId} ${message}`);
+  appendMessage("system", "系统", `${agent.name} 已开始后台执行,可以继续输入下一条消息。`);
+
+  const task = await api("/tasks", {
+    method: "POST",
+    body: JSON.stringify({
+      title: `与 ${agent.name} 对话`,
+      input: message,
+      agent_id: agentId,
+      metadata: { group_id: "default", mention: agentId },
+    }),
+  });
+  state.lastTask = task;
+  renderTask();
+
+  const running = await api(`/tasks/${task.task_id}/run`, { method: "POST" });
+  state.lastTask = running;
+  state.activeTaskId = running.task_id;
+  renderTask();
+
+  const completed = await waitForTask(task.task_id);
+  state.lastTask = completed;
+  state.activeTaskId = null;
+  renderTask();
+  if (completed.status === "failed") {
+    appendMessage("system", "系统", `${agent.name} 执行失败:${completed.error || "未知错误"}`);
+  } else {
+    appendMessage("agent", agent.name, completed.output || "(无输出)");
+  }
+  await refreshEvents();
+}
+
+els.chatForm.addEventListener("submit", async (event) => {
+  event.preventDefault();
+  const text = els.messageInput.value.trim();
+  if (!text) return;
+
+  els.messageInput.value = "";
+  try {
+    await sendMessage(text);
+  } catch (error) {
+    appendMessage("system", "系统", `请求失败:${error.message}`);
+  } finally {
+    els.messageInput.focus();
+  }
+});
+
+els.messageInput.addEventListener("input", renderMentionMenu);
+els.messageInput.addEventListener("click", renderMentionMenu);
+els.messageInput.addEventListener("blur", () => {
+  window.setTimeout(hideMentionMenu, 120);
+});
+els.messageInput.addEventListener("keydown", (event) => {
+  if (els.mentionMenu.hidden) return;
+
+  if (event.key === "ArrowDown") {
+    event.preventDefault();
+    state.activeMentionIndex = (state.activeMentionIndex + 1) % state.mentionOptions.length;
+    renderMentionMenu();
+  } else if (event.key === "ArrowUp") {
+    event.preventDefault();
+    state.activeMentionIndex =
+      (state.activeMentionIndex - 1 + state.mentionOptions.length) % state.mentionOptions.length;
+    renderMentionMenu();
+  } else if (event.key === "Enter" || event.key === "Tab") {
+    event.preventDefault();
+    chooseMention(state.mentionOptions[state.activeMentionIndex]);
+  } else if (event.key === "Escape") {
+    hideMentionMenu();
+  }
+});
+
+els.refreshButton.addEventListener("click", async () => {
+  try {
+    await loadAgents();
+    await refreshEvents();
+    appendMessage("system", "系统", "已刷新智能体和事件日志。");
+  } catch (error) {
+    appendMessage("system", "系统", `刷新失败:${error.message}`);
+  }
+});
+
+async function boot() {
+  try {
+    await loadAgents();
+    await refreshEvents();
+    appendMessage("system", "系统", "单聊模式已就绪。输入 @ 选择一个智能体后发送。");
+  } catch (error) {
+    els.statusText.textContent = "后端连接失败";
+    appendMessage("system", "系统", `启动失败:${error.message}`);
+  }
+}
+
+boot();

+ 61 - 0
Co-creation-projects/huailishang-AgentPlatformBase/frontend/index.html

@@ -0,0 +1,61 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>智能体平台</title>
+    <link rel="stylesheet" href="./styles.css?v=20260426-single-chat" />
+  </head>
+  <body>
+    <main class="shell">
+      <aside class="sidebar">
+        <div class="brand">
+          <div class="brand-mark">智</div>
+          <div>
+            <h1>智能体平台</h1>
+            <p>单聊工作台</p>
+          </div>
+        </div>
+
+        <section class="panel">
+          <div class="panel-title">智能体</div>
+          <div id="agentList" class="agent-list"></div>
+        </section>
+      </aside>
+
+      <section class="chat">
+        <header class="chat-header">
+          <div>
+            <h2>智能体单聊</h2>
+            <p id="statusText">正在连接后端...</p>
+          </div>
+          <button id="refreshButton" class="icon-button" title="刷新">↻</button>
+        </header>
+
+        <div id="messages" class="messages"></div>
+
+        <form id="chatForm" class="composer mention-composer">
+          <div class="input-wrap">
+            <input id="messageInput" type="text" placeholder="输入 @ 选择智能体,也可以直接发送给助手..." autocomplete="off" />
+            <div id="mentionMenu" class="mention-menu" hidden></div>
+          </div>
+          <button type="submit">发送</button>
+        </form>
+      </section>
+
+      <aside class="inspector">
+        <section class="panel">
+          <div class="panel-title">当前任务</div>
+          <div id="taskView" class="task-view">暂无任务</div>
+        </section>
+
+        <section class="panel events-panel">
+          <div class="panel-title">事件日志</div>
+          <div id="eventList" class="event-list"></div>
+        </section>
+      </aside>
+    </main>
+
+    <script src="./app.js?v=20260426-single-chat"></script>
+  </body>
+</html>

+ 419 - 0
Co-creation-projects/huailishang-AgentPlatformBase/frontend/styles.css

@@ -0,0 +1,419 @@
+:root {
+  color-scheme: light;
+  font-family: Inter, "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
+  --bg: #f5f7fb;
+  --surface: #ffffff;
+  --surface-soft: #f0f4f8;
+  --line: #d8e0ea;
+  --text: #172033;
+  --muted: #667085;
+  --accent: #2563eb;
+  --accent-soft: #e7efff;
+  --ok: #138a53;
+  --warn: #b45309;
+  --danger: #b42318;
+  --shadow: 0 14px 34px rgba(15, 23, 42, 0.08);
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  background: var(--bg);
+  color: var(--text);
+}
+
+button,
+input,
+select {
+  font: inherit;
+}
+
+.shell {
+  display: grid;
+  grid-template-columns: 280px minmax(0, 1fr) 340px;
+  min-height: 100vh;
+}
+
+.sidebar,
+.inspector {
+  border-right: 1px solid var(--line);
+  background: #fbfcff;
+  padding: 18px;
+}
+
+.inspector {
+  border-right: 0;
+  border-left: 1px solid var(--line);
+}
+
+.brand {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 18px;
+}
+
+.brand-mark {
+  display: grid;
+  place-items: center;
+  width: 40px;
+  height: 40px;
+  border-radius: 8px;
+  background: var(--accent);
+  color: #fff;
+  font-weight: 800;
+}
+
+h1,
+h2,
+p {
+  margin: 0;
+}
+
+h1 {
+  font-size: 18px;
+}
+
+h2 {
+  font-size: 20px;
+}
+
+.brand p,
+.chat-header p {
+  color: var(--muted);
+  font-size: 13px;
+  margin-top: 4px;
+}
+
+.panel {
+  background: var(--surface);
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  padding: 12px;
+  box-shadow: var(--shadow);
+}
+
+.panel + .panel {
+  margin-top: 14px;
+}
+
+.panel-title {
+  color: var(--muted);
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: 0;
+  margin-bottom: 10px;
+  text-transform: uppercase;
+}
+
+.agent-list {
+  display: grid;
+  gap: 8px;
+}
+
+.agent-item {
+  width: 100%;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface);
+  padding: 10px;
+  text-align: left;
+  cursor: pointer;
+}
+
+.agent-item.active {
+  border-color: var(--accent);
+  background: var(--accent-soft);
+}
+
+.agent-name {
+  font-size: 14px;
+  font-weight: 700;
+}
+
+.agent-meta {
+  color: var(--muted);
+  font-size: 12px;
+  margin-top: 4px;
+}
+
+.chat {
+  display: grid;
+  grid-template-rows: auto 1fr auto;
+  min-width: 0;
+}
+
+.chat-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: 1px solid var(--line);
+  background: var(--surface);
+  padding: 16px 20px;
+}
+
+.icon-button {
+  width: 38px;
+  height: 38px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface);
+  cursor: pointer;
+}
+
+.messages {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  overflow: auto;
+  padding: 20px;
+}
+
+.message {
+  max-width: min(760px, 88%);
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface);
+  padding: 12px 14px;
+  box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05);
+}
+
+.message.user {
+  align-self: flex-end;
+  border-color: #bdd0ff;
+  background: #eef4ff;
+}
+
+.message.agent {
+  align-self: flex-start;
+}
+
+.message.system {
+  align-self: center;
+  max-width: 620px;
+  background: var(--surface-soft);
+  color: var(--muted);
+}
+
+.message-head {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  color: var(--muted);
+  font-size: 12px;
+  margin-bottom: 7px;
+}
+
+.message-body {
+  line-height: 1.55;
+  white-space: normal;
+  overflow-wrap: anywhere;
+}
+
+.message-body a {
+  color: var(--accent);
+  font-weight: 700;
+  text-decoration: underline;
+  text-underline-offset: 2px;
+}
+
+.message-body h1,
+.message-body h2,
+.message-body h3 {
+  color: var(--text);
+  line-height: 1.25;
+  margin: 12px 0 8px;
+}
+
+.message-body h1 {
+  font-size: 20px;
+}
+
+.message-body h2 {
+  font-size: 18px;
+}
+
+.message-body h3 {
+  font-size: 16px;
+}
+
+.message-body p {
+  margin: 8px 0;
+}
+
+.message-body ul,
+.message-body ol {
+  margin: 8px 0;
+  padding-left: 22px;
+}
+
+.message-body li + li {
+  margin-top: 4px;
+}
+
+.message-body pre {
+  overflow: auto;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: #f8fafc;
+  padding: 10px;
+}
+
+.message-body code {
+  border-radius: 5px;
+  background: #eef2f7;
+  color: #111827;
+  font-family: Consolas, "SFMono-Regular", monospace;
+  font-size: 0.92em;
+  padding: 2px 5px;
+}
+
+.message-body pre code {
+  background: transparent;
+  padding: 0;
+}
+
+.message-body blockquote {
+  border-left: 3px solid var(--line);
+  color: var(--muted);
+  margin: 8px 0;
+  padding-left: 10px;
+}
+
+.composer {
+  display: grid;
+  grid-template-columns: 1fr auto;
+  gap: 10px;
+  border-top: 1px solid var(--line);
+  background: var(--surface);
+  padding: 14px;
+}
+
+.composer input {
+  min-width: 0;
+  width: 100%;
+  height: 42px;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: #fff;
+  padding: 0 12px;
+}
+
+.input-wrap {
+  position: relative;
+  min-width: 0;
+}
+
+.mention-menu {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: calc(100% + 8px);
+  z-index: 10;
+  max-height: 260px;
+  overflow: auto;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--surface);
+  box-shadow: var(--shadow);
+  padding: 6px;
+}
+
+.mention-option {
+  display: grid;
+  width: 100%;
+  border: 1px solid transparent;
+  border-radius: 6px;
+  background: #f7f9fc;
+  color: var(--text);
+  cursor: pointer;
+  padding: 9px 10px;
+  text-align: left;
+}
+
+.mention-option:hover {
+  border-color: #cbd5e1;
+  background: #eef2f7;
+}
+
+.mention-option.active {
+  border-color: #1d4ed8;
+  background: #1f2937;
+  color: #fff;
+}
+
+.mention-option strong {
+  font-size: 14px;
+}
+
+.mention-option span {
+  color: var(--muted);
+  font-size: 12px;
+  margin-top: 3px;
+}
+
+.mention-option.active span {
+  color: #d1d5db;
+}
+
+.composer button {
+  height: 42px;
+  border: 0;
+  border-radius: 8px;
+  background: var(--accent);
+  color: #fff;
+  cursor: pointer;
+  font-weight: 700;
+  padding: 0 18px;
+}
+
+.task-view,
+.event-list {
+  color: var(--muted);
+  font-size: 13px;
+  line-height: 1.5;
+}
+
+.task-card,
+.event-item {
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: #fff;
+  padding: 10px;
+}
+
+.event-item + .event-item {
+  margin-top: 8px;
+}
+
+.event-type {
+  color: var(--text);
+  font-weight: 700;
+}
+
+.events-panel {
+  max-height: calc(100vh - 190px);
+  overflow: auto;
+}
+
+@media (max-width: 1000px) {
+  .shell {
+    grid-template-columns: 1fr;
+  }
+
+  .sidebar,
+  .inspector {
+    border: 0;
+  }
+
+  .composer {
+    grid-template-columns: 1fr;
+  }
+
+  .message {
+    max-width: 100%;
+  }
+}

+ 17 - 0
Co-creation-projects/huailishang-AgentPlatformBase/main.py

@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+import os
+
+import uvicorn
+
+from backend.config import settings
+
+
+if __name__ == "__main__":
+    uvicorn.run(
+        "backend.main:app",
+        host=os.getenv("APP_HOST", settings.app_host),
+        port=int(os.getenv("APP_PORT", str(settings.app_port))),
+        access_log=os.getenv("APP_ACCESS_LOG", "false").strip().lower() in {"1", "true", "yes", "on"},
+        reload=False,
+    )

+ 11 - 0
Co-creation-projects/huailishang-AgentPlatformBase/requirements.txt

@@ -0,0 +1,11 @@
+fastapi>=0.110
+uvicorn[standard]>=0.27
+pydantic>=2.0
+python-dotenv>=1.0
+requests>=2.31
+hello-agents==0.2.9
+openai>=1.12.0
+tavily-python>=0.5.0
+ddgs>=9.6.1
+loguru>=0.7.3
+typing_extensions>=4.8.0

+ 67 - 0
Co-creation-projects/huailishang-AgentPlatformBase/smoke_test.py

@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+from fastapi.testclient import TestClient
+
+from backend.main import app
+
+
+client = TestClient(app)
+
+
+def assert_ok(response):
+    assert response.status_code == 200, response.text
+    return response.json()
+
+
+def main() -> None:
+    assert_ok(client.get("/health"))
+    frontend = client.get("/app/")
+    assert frontend.status_code == 200
+    assert "智能体平台" in frontend.text
+
+    agents = assert_ok(client.get("/agents"))
+    agent_ids = {agent["agent_id"] for agent in agents["agents"]}
+    assert agents["total"] == 2
+    assert "planner" not in agent_ids
+    assert {"deep_research", "rss_digest"} <= agent_ids
+
+    task = assert_ok(
+        client.post(
+            "/tasks",
+            json={
+                "title": "Research adapter test",
+                "input": "agent platform architecture",
+                "agent_id": "deep_research",
+                "metadata": {"dry_run": True},
+            },
+        )
+    )
+    completed = assert_ok(client.post(f"/tasks/{task['task_id']}/run?background=false"))
+    assert completed["status"] == "completed"
+
+    batch = assert_ok(
+        client.post(
+            "/batch/run",
+            json={
+                "requests": {
+                    "deep_research": {
+                        "input": "ship v1",
+                        "context": {"mode": "group_chat"},
+                    },
+                    "rss_digest": {
+                        "input": "latest rss",
+                        "context": {"mode": "group_chat"},
+                    },
+                }
+            },
+        )
+    )
+    assert "deep_research" in batch["responses"]
+    assert "rss_digest" in batch["responses"]
+    assert_ok(client.get("/events"))
+
+    print("chapter16 platform smoke test passed")
+
+
+if __name__ == "__main__":
+    main()