Преглед изворни кода

feat: add AgentPlatformBase graduation project

SHL пре 1 месец
родитељ
комит
fda6a635c9
41 измењених фајлова са 4576 додато и 0 уклоњено
  1. 65 0
      Co-creation-projects/huailishang-AgentPlatformBase/.env.example
  2. 6 0
      Co-creation-projects/huailishang-AgentPlatformBase/.gitignore
  3. 193 0
      Co-creation-projects/huailishang-AgentPlatformBase/README.md
  4. 8 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/README.md
  5. 95 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/README.md
  6. 148 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/config/sources.json
  7. 38 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/config/sources_full.opml
  8. 20 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/main.py
  9. 11 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/scripts/run_daily.ps1
  10. 1 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/__init__.py
  11. 145 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/config.py
  12. 259 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/db.py
  13. 309 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/digest.py
  14. 129 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/extractor.py
  15. 128 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/feeds.py
  16. 151 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/llm.py
  17. 443 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/pipeline.py
  18. 254 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/rss_digest/src/rss_digest/ui_server.py
  19. 1 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/__init__.py
  20. 14 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/__init__.py
  21. 0 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/adapters/__init__.py
  22. 189 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/adapters/deep_research.py
  23. 251 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/adapters/rss_digest.py
  24. 31 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/base.py
  25. 26 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/profiles.py
  26. 34 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/registry.py
  27. 85 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/config.py
  28. 44 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/events.py
  29. 108 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/main.py
  30. 149 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/maintenance.py
  31. 74 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/models.py
  32. 5 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/tasks/__init__.py
  33. 17 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/tasks/batch.py
  34. 60 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/tasks/manager.py
  35. 56 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/tasks/runner.py
  36. 461 0
      Co-creation-projects/huailishang-AgentPlatformBase/frontend/app.js
  37. 61 0
      Co-creation-projects/huailishang-AgentPlatformBase/frontend/index.html
  38. 419 0
      Co-creation-projects/huailishang-AgentPlatformBase/frontend/styles.css
  39. 16 0
      Co-creation-projects/huailishang-AgentPlatformBase/main.py
  40. 5 0
      Co-creation-projects/huailishang-AgentPlatformBase/requirements.txt
  41. 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. Copy real values from chapter14/chapter15 .env when needed.
+LLM_PROVIDER=
+LLM_MODEL_ID=
+LLM_API_KEY=
+LLM_BASE_URL=
+LLM_TIMEOUT=120
+
+# Search settings for the future chapter14 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
+
+# Chapter14 integration path.
+CHAPTER14_BACKEND_PATH=../chapter14/helloagents-deepresearch-fixed/backend/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/

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

@@ -0,0 +1,193 @@
+# AgentPlatformBase - 双智能体任务平台
+
+`AgentPlatformBase` 是一个面向 Hello-Agents 第 16 章毕业项目的轻量智能体平台。它用 FastAPI 提供统一后端,用浏览器前端承载对话入口,并接入两个有明确业务价值的智能体:搜索员 `deep_research` 和资讯员 `rss_digest`。
+
+## 核心功能
+
+- 统一智能体注册表:后端通过 `AgentRegistry` 管理不同智能体。
+- 后台任务执行:长任务默认后台运行,前端轮询任务状态,不阻塞输入框。
+- 搜索员:封装 chapter14 的 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
+    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
+- Requests / Feedparser / BeautifulSoup / Readability
+- 原生 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 课程毕业设计提交,遵循仓库根目录许可证约束。

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

@@ -0,0 +1,8 @@
+# deep_research
+
+`deep_research` 当前通过 `backend/agents/adapters/deep_research.py` 封装 chapter14 的 `DeepResearchAgent`。
+
+本目录预留给后续把搜索员业务实现内迁到 chapter16 时使用。当前运行数据写入 `data/deep_research/`:
+
+- `runs/`:单次运行过程产物,可按保留期清理。
+- `notes/`:研究笔记和索引,默认长期保留。

+ 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


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

@@ -0,0 +1,189 @@
+from __future__ import annotations
+
+import importlib
+import sys
+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 chapter14 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()
+        timings: dict[str, float] = {}
+
+        started = perf_counter()
+        cleanup_stats = cleanup_deep_research_artifacts()
+        timings["cleanup_seconds"] = round(perf_counter() - started, 3)
+
+        if request.context.get("mode") == "group_chat":
+            return (
+                "deep_research 是长耗时研究流程。请单独使用 @deep_research 提交明确研究主题。",
+                {"skipped": True, "reason": "batch_guard", "cleanup": cleanup_stats},
+            )
+
+        chapter14_path = Path(settings.chapter14_backend_path).resolve()
+        if not chapter14_path.exists():
+            return (
+                f"chapter14 后端路径不存在,无法运行 deep_research:{chapter14_path}",
+                {
+                    "ready": False,
+                    "chapter14_backend_path": str(chapter14_path),
+                    "cleanup": cleanup_stats,
+                },
+            )
+
+        if request.context.get("dry_run"):
+            return (
+                "deep_research 已接入 chapter14 后端路径,真实运行时会调用 chapter14 的 DeepResearchAgent。",
+                {
+                    "ready": True,
+                    "chapter14_backend_path": str(chapter14_path),
+                    "cleanup": cleanup_stats,
+                },
+            )
+
+        started = perf_counter()
+        DeepResearchAgent, Configuration = self._load_chapter14_types(chapter14_path)
+        timings["load_chapter14_seconds"] = round(perf_counter() - started, 3)
+
+        started = perf_counter()
+        config = Configuration.from_env(overrides=self._chapter14_overrides())
+        agent = DeepResearchAgent(config=config)
+        timings["agent_init_seconds"] = round(perf_counter() - started, 3)
+
+        started = perf_counter()
+        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 ""
+        completed_items = [
+            item for item in todo_items if item.get("status") == "completed" and item.get("summary")
+        ]
+        artifacts: dict[str, Any] = {
+            "report_markdown": report,
+            "todo_items": todo_items,
+            "cleanup": cleanup_stats,
+        }
+        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)
+
+        if todo_items and not completed_items:
+            output = (
+                "搜索员没有拿到可用的搜索总结,因此未返回正式研究报告。\n"
+                "可能原因:搜索后端无结果、网络 API 调用失败,或任务执行阶段没有产出摘要。\n"
+                "请查看后端日志和 data/deep_research/runs 目录下的 task_* 文件。"
+            )
+            return output, artifacts
+
+        output = report.strip()
+        if not output:
+            output = "deep_research 已完成,但没有生成报告正文。"
+
+        return output, artifacts
+
+    def _load_chapter14_types(self, chapter14_path: Path) -> tuple[type[Any], type[Any]]:
+        path_text = str(chapter14_path)
+        if path_text not in sys.path:
+            sys.path.insert(0, path_text)
+
+        # Chapter14 loads its own .env on import; reload chapter16 .env afterwards.
+        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 _chapter14_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

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

@@ -0,0 +1,85 @@
+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)
+
+
+@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 = os.getenv(
+        "CHAPTER14_BACKEND_PATH",
+        str((ROOT_DIR.parents[1] / "chapter14" / "helloagents-deepresearch-fixed" / "backend" / "src").resolve()),
+    )
+
+    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()

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

@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+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
+
+
+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%;
+  }
+}

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

@@ -0,0 +1,16 @@
+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))),
+        reload=False,
+    )

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

@@ -0,0 +1,5 @@
+fastapi>=0.110
+uvicorn[standard]>=0.27
+pydantic>=2.0
+python-dotenv>=1.0
+requests>=2.31

+ 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()