From 35d0dcd6f2ad9bb45da7bf9514af29af80b02b13 Mon Sep 17 00:00:00 2001
From: hataa <79907651+hata33@users.noreply.github.com>
Date: Sat, 24 Oct 2026 08:50:56 +0800
Subject: [PATCH] feat(plugins): add sectioned long-form report writing example
(#7371)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(plugins): add sectioned long-form report writing example
A standalone example extension whose save_section model tool stores a
万字-scale report section by section (SQLite, per-user and per-thread)
and assembles every section into one markdown document on a final
section_index=999 call. The assembled document is returned as a tool
artifact with only a short status line re-entering the model context, so
the full report never consumes the context budget.
Assembly drops a section's leading heading when it restates the section
title (models habitually open each section with '#
' while the
assembler already emits '## '), which otherwise doubles every
heading in the document. The outline section passes through unwrapped
and keeps its own heading as the document title.
* fix(frontend): register report sections in extension catalog
* docs(report-sections): correct the test command path
---------
Co-authored-by: Willem Jiang
---
.../tests/test_report_sections_extension.py | 198 ++++++++++++++++++
examples/README.md | 5 +-
.../README.md | 65 ++++++
.../__init__.py | 120 +++++++++++
.../sections.py | 120 +++++++++++
.../pyproject.toml | 17 ++
frontend/src/core/extensions/catalog.ts | 5 +
frontend/src/core/i18n/locales/en-US.ts | 5 +
frontend/src/core/i18n/locales/types.ts | 1 +
frontend/src/core/i18n/locales/zh-CN.ts | 4 +
10 files changed, 538 insertions(+), 2 deletions(-)
create mode 100644 backend/tests/test_report_sections_extension.py
create mode 100644 examples/deerflow-extension-report-sections/README.md
create mode 100644 examples/deerflow-extension-report-sections/deerflow_extension_report_sections/__init__.py
create mode 100644 examples/deerflow-extension-report-sections/deerflow_extension_report_sections/sections.py
create mode 100644 examples/deerflow-extension-report-sections/pyproject.toml
diff --git a/backend/tests/test_report_sections_extension.py b/backend/tests/test_report_sections_extension.py
new file mode 100644
index 0000000000..a842b5c250
--- /dev/null
+++ b/backend/tests/test_report_sections_extension.py
@@ -0,0 +1,198 @@
+"""Sectioned long-form writing plugin through the real plugin tool path."""
+
+import asyncio
+from pathlib import Path
+
+import pytest
+from deerflow_extension_api.auth import ExtensionPrincipal
+from deerflow_extension_api.plugins import ToolContext
+
+from deerflow.extensions.loader import ExtensionSpec, load_extensions
+from deerflow.extensions.plugin_tools import build_plugin_tools
+
+EXAMPLE = Path(__file__).resolve().parents[2] / "examples/deerflow-extension-report-sections"
+
+OUTLINE = "# 2026 年新能源汽车行业研究报告\n\n本报告分五章。"
+SECTION_WITH_TITLE = "# 一、行业概述与研究方法\n\n行业概述正文。"
+SECTION_HASH_SUFFIX = "### 二、市场格局与竞争态势 ###\n\n竞争态势正文。"
+
+
+@pytest.fixture
+def syspath(monkeypatch):
+ monkeypatch.syspath_prepend(str(EXAMPLE))
+
+
+@pytest.fixture
+def load(syspath, tmp_path):
+ def _load(**config):
+ loaded, diagnostics = load_extensions(
+ [
+ ExtensionSpec(
+ use="deerflow_extension_report_sections:install",
+ config={"enabled": True, "storage_path": str(tmp_path / "sections.sqlite"), **config},
+ )
+ ]
+ )
+ assert not diagnostics, diagnostics
+ return loaded
+
+ return _load
+
+
+def handler(loaded):
+ ((_, plugin),) = loaded.plugins
+ (tool,) = plugin.tools
+ assert tool.name == "save_section"
+ assert tool.response_format == "content_and_artifact"
+ return tool.handler
+
+
+def context(user="alice", thread="thread-1"):
+ return ToolContext(ExtensionPrincipal(user), {"enabled": True}, thread)
+
+
+def save(handle, section_index, title, content, user="alice", thread="thread-1"):
+ return asyncio.run(handle({"section_index": section_index, "title": title, "content": content}, context(user, thread)))
+
+
+def assemble(handle, user="alice", thread="thread-1"):
+ return asyncio.run(handle({"section_index": 999, "title": "__assemble__", "content": ""}, context(user, thread)))
+
+
+def seed(handle):
+ save(handle, 0, "2026 年新能源汽车行业研究报告", OUTLINE)
+ save(handle, 1, "一、行业概述与研究方法", SECTION_WITH_TITLE)
+ save(handle, 2, "二、市场格局与竞争态势", SECTION_HASH_SUFFIX)
+
+
+def test_registration_exposes_one_assembling_tool(load):
+ handle = handler(load())
+ status = save(handle, 1, "一、章节", "正文内容。")
+ assert status.content.startswith("OK section 1")
+ assert status.artifact is None
+
+
+def test_assembly_strips_duplicate_headings_and_keeps_unique_ones(load):
+ handle = handler(load())
+ seed(handle)
+ result = assemble(handle)
+ assert result.content.startswith("OK assembled 3 sections")
+ document = result.artifact["document"]
+ # The assembler's own heading is the only occurrence for each section.
+ assert document.count("一、行业概述与研究方法") == 1
+ assert document.count("二、市场格局与竞争态势") == 1
+ assert "### 二、市场格局与竞争态势 ###" not in document
+ assert document.count("## 一、行业概述与研究方法") == 1
+ # The outline passes through unwrapped and keeps its own opening heading.
+ assert document.startswith("# 2026 年新能源汽车行业研究报告")
+ assert result.artifact["filename"].startswith("report-")
+ assert result.artifact["sections"] == 3
+
+
+def test_strip_duplicate_title_heading_variants(syspath):
+ from deerflow_extension_report_sections.sections import strip_duplicate_title_heading
+
+ assert strip_duplicate_title_heading("# 摘要\n正文", "摘要") == "正文"
+ assert strip_duplicate_title_heading("### 摘要 ###\n正文", " 摘要 ") == "正文"
+ assert strip_duplicate_title_heading("正文没有标题", "摘要") == "正文没有标题"
+ # A heading that does not restate the title is content, not a duplicate.
+ assert strip_duplicate_title_heading("# 其他标题\n正文", "摘要") == "# 其他标题\n正文"
+
+
+def test_assemble_document_counts_stripped_bodies(syspath):
+ from deerflow_extension_report_sections.sections import assemble_document
+
+ rows = [
+ {"section_index": 0, "title": "大纲", "content": "# 大纲\n引言。"},
+ {"section_index": 1, "title": "一、背景", "content": "# 一、背景\n背景正文。"},
+ ]
+ document, total_chars = assemble_document(rows)
+ # The outline keeps its own heading (it becomes the document title);
+ # wrapped sections drop their duplicate opening heading.
+ assert total_chars == len("# 大纲\n引言。") + len("背景正文。")
+ assert document.startswith("# 大纲")
+ assert "\n\n## 一、背景\n\n背景正文。" in document
+
+
+def test_sections_are_scoped_per_user_and_thread(load):
+ handle = handler(load())
+ seed(handle)
+ for user, thread in (("bob", "thread-1"), ("alice", "thread-2")):
+ empty = assemble(handle, user=user, thread=thread)
+ assert empty.content.startswith("ERROR: no sections saved yet")
+
+
+def test_empty_section_content_is_refused(load):
+ handle = handler(load())
+ refused = save(handle, 1, "一、章节", " \n")
+ assert refused.content.startswith("ERROR: content is empty")
+
+
+def test_missing_thread_context_is_refused(load):
+ handle = handler(load())
+ no_thread = ToolContext(ExtensionPrincipal("alice"), {"enabled": True}, None)
+ refused = asyncio.run(handle({"section_index": 1, "title": "一", "content": "正文"}, no_thread))
+ assert refused.content.startswith("ERROR: no user or thread context")
+
+
+def test_resaving_a_section_replaces_it(load):
+ handle = handler(load())
+ save(handle, 1, "一、章节", "第一版正文。")
+ save(handle, 1, "一、章节", "第二版正文。")
+ result = assemble(handle)
+ assert result.artifact["sections"] == 1
+ assert "第一版" not in result.artifact["document"]
+ assert "第二版正文。" in result.artifact["document"]
+
+
+def test_oversized_assembly_is_refused_but_sections_remain(load):
+ handle = handler(load())
+ body = "字" * 20_000
+ for index in range(1, 5):
+ save(handle, index, f"第{index}章", body)
+ refused = assemble(handle)
+ assert "over the" in refused.content
+ assert refused.artifact is None
+ # The stored sections are intact for a replacement save.
+ trimmed = save(handle, 1, "第1章", "短正文。")
+ assert trimmed.content.startswith("OK section 1")
+
+
+def test_disabled_contribution_builds_no_tool(load):
+ loaded = load(enabled=False)
+ tools = build_plugin_tools(loaded, groups=("extensions",))
+ assert tools == []
+
+
+def test_real_tool_dispatch_delivers_document_as_artifact(load):
+ from langchain_core.messages import AIMessage
+ from langgraph.graph import END, START, MessagesState, StateGraph
+ from langgraph.prebuilt import ToolNode
+
+ (tool,) = build_plugin_tools(load(), groups=("extensions",))
+ graph = StateGraph(MessagesState)
+ graph.add_node("tools", ToolNode([tool]))
+ graph.add_edge(START, "tools")
+ graph.add_edge("tools", END)
+ compiled = graph.compile()
+
+ def dispatch(section_index, title, content):
+ return asyncio.run(
+ compiled.ainvoke(
+ {"messages": [AIMessage(content="", tool_calls=[{"id": "c", "name": tool.name, "args": {"section_index": section_index, "title": title, "content": content}}])]},
+ context={"user_id": "alice", "thread_id": "thread-1"},
+ )
+ )["messages"][-1]
+
+ saved = dispatch(1, "一、章节", "正文。")
+ assert saved.status != "error"
+ assert saved.content.startswith("OK section 1")
+ assert getattr(saved, "artifact", None) is None
+
+ assembled = dispatch(999, "__assemble__", "")
+ assert assembled.status != "error"
+ assert assembled.content.startswith("OK assembled 1 sections")
+ artifact = assembled.artifact
+ assert artifact["filename"].startswith("report-")
+ assert artifact["document"].endswith("\n")
+ assert "## 一、章节" in artifact["document"]
diff --git a/examples/README.md b/examples/README.md
index 0f31fa25af..c936b46a94 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -10,8 +10,9 @@ independent Python package using the public `deerflow-extension-api` contract.
| [Agent teams](deerflow-extension-agent-teams/README.md) | Full Agent orchestration. Use this after the basic contribution and lifecycle patterns are familiar. |
Additional examples cover [text-list classification](deerflow-extension-jev-classify/README.md),
-[context pruning](deerflow-extension-jev-context/README.md), and
-[advisory screening of fetched content](deerflow-extension-jev-screening/README.md).
+[context pruning](deerflow-extension-jev-context/README.md),
+[advisory screening of fetched content](deerflow-extension-jev-screening/README.md), and
+[sectioned long-form report writing](deerflow-extension-report-sections/README.md).
## Edit, test, check, then install
diff --git a/examples/deerflow-extension-report-sections/README.md b/examples/deerflow-extension-report-sections/README.md
new file mode 100644
index 0000000000..a94be39cf7
--- /dev/null
+++ b/examples/deerflow-extension-report-sections/README.md
@@ -0,0 +1,65 @@
+# Report sections — sectioned long-form writing plugin example
+
+For the shared edit → test → packaging check → install workflow, see the
+[existing-example guide](../README.md).
+
+A compact example of a **stateful model tool**: the agent writes a 万字-scale
+long report section by section with `save_section`, and a final
+`section_index=999` call assembles every stored section into one markdown
+document. The assembled report is returned as a tool artifact (visible in the
+UI) with only a short status summary re-entering the model context, so the
+full document never consumes the agent's context budget.
+
+Assembly drops a section's leading heading when it restates the section title
+(models habitually open each section with `# ` while the assembler
+already emits `## `), which otherwise produces doubled headings across
+the whole report. Sections are scoped per user and per thread in the plugin's
+own SQLite file — no host database or host-internal imports.
+
+## Deployment
+
+Install this directory using the existing trusted Python extension manager.
+From `backend/`:
+
+```sh
+uv run deerflow extensions install ../examples/deerflow-extension-report-sections --yes
+```
+
+Then set the deployment-owned `config` on the registered plugin entry before
+restarting Gateway. Example configuration:
+
+```yaml
+plugins:
+ - use: deerflow_extension_report_sections:install
+ enabled: true
+ config:
+ enabled: true
+ storage_path: /var/lib/deerflow/report-sections.sqlite
+```
+
+The outer `enabled` controls package loading; `config.enabled` controls the
+tool contribution. Mount a persistent writable directory for `storage_path`.
+The file stores user section data, not deployment settings.
+
+## Tool contract
+
+`save_section(section_index, title, content)`:
+
+- `section_index=0` — store the outline (passed through unwrapped on assembly).
+- `1..N` — store one chapter (keep each call within roughly 1200–2000
+ characters to stay inside the per-completion output budget; hard cap 24000).
+- `999` with `title="__assemble__"` — assemble all stored sections into
+ `report-.md`, delivered as an artifact. Assemblies over 60 KiB are
+ refused with a clear status; the sections remain stored.
+
+The tool result envelope is capped at 64 KiB by the host; this example keeps
+the model-visible content to one status line and carries the document in the
+artifact.
+
+## Tests
+
+From the DeerFlow checkout root with the backend environment prepared:
+
+```sh
+uv run --project backend --no-sync python -m pytest backend/tests/test_report_sections_extension.py -q
+```
diff --git a/examples/deerflow-extension-report-sections/deerflow_extension_report_sections/__init__.py b/examples/deerflow-extension-report-sections/deerflow_extension_report_sections/__init__.py
new file mode 100644
index 0000000000..d0aff98e76
--- /dev/null
+++ b/examples/deerflow-extension-report-sections/deerflow_extension_report_sections/__init__.py
@@ -0,0 +1,120 @@
+"""Sectioned long-form writing plugin. No imports from DeerFlow host internals."""
+
+import asyncio
+
+from deerflow_extension_api import ModelTool, PluginContribution, PluginToolResult, extension
+
+from .sections import (
+ ASSEMBLE_INDEX,
+ ASSEMBLE_TITLE,
+ MAX_CONTENT_CHARS,
+ MAX_DOCUMENT_BYTES,
+ MAX_TITLE_CHARS,
+ SectionStore,
+)
+
+TOOL_DESCRIPTION = (
+ "Persist one section of a long report, then assemble all sections into the final document. "
+ "Call with section_index=0 first to store the outline, then 1..N in order for chapters "
+ "(keep each section within roughly 1200-2000 characters), and finally section_index=999 "
+ "with title '__assemble__' to assemble every stored section into one markdown report, "
+ "returned as a downloadable artifact with a short status summary. "
+ "Sections are scoped to the current user and thread."
+)
+
+INPUT_SCHEMA = {
+ "type": "object",
+ "properties": {
+ "section_index": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": ASSEMBLE_INDEX,
+ "description": "0 = outline, 1..N = chapters in order, 999 = assemble.",
+ },
+ "title": {
+ "type": "string",
+ "maxLength": MAX_TITLE_CHARS,
+ "description": "Section title; use '__assemble__' with index 999.",
+ },
+ "content": {
+ "type": "string",
+ "maxLength": MAX_CONTENT_CHARS,
+ "description": "Full markdown content of this section; ignored when assembling.",
+ },
+ },
+ "required": ["section_index", "title", "content"],
+ "additionalProperties": False,
+}
+
+
+def _error(message: str) -> PluginToolResult:
+ return PluginToolResult(content=f"ERROR: {message}", artifact=None)
+
+
+@extension(api="0.2.8", name="report-sections")
+def install(registry, config):
+ enabled = config.get("enabled", False)
+ if type(enabled) is not bool or not isinstance(config.get("storage_path"), str):
+ raise ValueError("Configure boolean enabled and an absolute storage_path at deployment")
+ store = SectionStore(config["storage_path"])
+
+ async def save_section(payload, context):
+ section_index = payload["section_index"]
+ title = payload["title"].strip()
+ owner = context.principal.user_id
+ thread_id = context.thread_id
+ if not owner or not thread_id:
+ return _error("no user or thread context; cannot save section")
+
+ if section_index == ASSEMBLE_INDEX or title == ASSEMBLE_TITLE:
+ assembled = await asyncio.to_thread(store.assemble, owner, thread_id)
+ if assembled is None:
+ return _error("no sections saved yet; nothing to assemble")
+ filename, document, count, total_chars = assembled
+ if len(document.encode("utf-8")) > MAX_DOCUMENT_BYTES:
+ return _error(f"assembled report is {len(document.encode('utf-8'))} bytes, over the {MAX_DOCUMENT_BYTES}-byte tool-result cap; sections remain stored — assemble fewer sections per thread")
+ # content is model-visible and stays short; the artifact carries the
+ # full document to the UI without re-entering the model context.
+ return PluginToolResult(
+ content=(f"OK assembled {count} sections, {total_chars} chars into {filename}. Give the user a brief completion summary now (section list and total length)."),
+ artifact={
+ "filename": filename,
+ "sections": count,
+ "chars": total_chars,
+ "document": document,
+ },
+ )
+
+ content = payload["content"]
+ if not content.strip():
+ return _error("content is empty")
+ await asyncio.to_thread(store.save, owner, thread_id, int(section_index), title, content)
+ return PluginToolResult(
+ content=f"OK section {section_index} '{title}' saved ({len(content)} chars). Continue with the next section.",
+ artifact=None,
+ )
+
+ contribution = PluginContribution(
+ namespace="community.report-sections",
+ title="分段长文写作 / Sectioned long-form writing",
+ description=(
+ "把万字级长报告分节保存,最后拼装成完整交付文档;拼装时自动去掉与节标题重复的正文标题。"
+ " / Store a long report section by section and assemble the final document,"
+ " dropping content headings that duplicate the assembled section titles."
+ ),
+ enabled=enabled,
+ tools=(
+ ModelTool(
+ "save_section",
+ TOOL_DESCRIPTION,
+ INPUT_SCHEMA,
+ save_section,
+ response_format="content_and_artifact",
+ ),
+ ),
+ )
+ if registry.plugin(contribution) is not True:
+ raise RuntimeError("This example requires the full-stack plugin host contract")
+
+
+__all__ = ["install"]
diff --git a/examples/deerflow-extension-report-sections/deerflow_extension_report_sections/sections.py b/examples/deerflow-extension-report-sections/deerflow_extension_report_sections/sections.py
new file mode 100644
index 0000000000..548aba0dcc
--- /dev/null
+++ b/examples/deerflow-extension-report-sections/deerflow_extension_report_sections/sections.py
@@ -0,0 +1,120 @@
+"""Section storage and report assembly. No imports from DeerFlow host internals."""
+
+import re
+import sqlite3
+from contextlib import closing
+from pathlib import Path
+
+ASSEMBLE_INDEX = 999
+ASSEMBLE_TITLE = "__assemble__"
+
+MAX_TITLE_CHARS = 200
+MAX_CONTENT_CHARS = 24_000
+# The host caps every plugin tool envelope at 64 KiB; leave room for the
+# status text and JSON escaping before refusing an oversized assembly.
+MAX_DOCUMENT_BYTES = 60_000
+
+_DDL = """CREATE TABLE IF NOT EXISTS report_sections (
+ owner TEXT NOT NULL,
+ thread_id TEXT NOT NULL,
+ section_index INTEGER NOT NULL,
+ title TEXT NOT NULL,
+ content TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (owner, thread_id, section_index)
+)"""
+
+
+def _norm_heading(text: str) -> str:
+ """Whitespace- and marker-insensitive form for heading comparison."""
+ return re.sub(r"[\s#]+", "", text)
+
+
+def strip_duplicate_title_heading(content: str, title: str) -> str:
+ """Drop a leading heading that restates the section title.
+
+ Models tend to open each stored section with ``# ``; the assembler
+ already emits ``## `` for every non-outline section, so keeping
+ both produced doubled headings throughout assembled reports.
+ """
+ stripped = content.lstrip()
+ first_line, _, rest = stripped.partition("\n")
+ match = re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$", first_line)
+ if match and _norm_heading(match.group(1)) == _norm_heading(title):
+ return rest.lstrip("\n")
+ return stripped
+
+
+def assemble_document(rows: list[sqlite3.Row]) -> tuple[str, int]:
+ """Build the final markdown document and report its body size.
+
+ ``section_index`` 0 is the outline: it passes through unwrapped, so its own
+ leading heading is the document title and must be kept. Every other
+ section is preceded by one ``## `` heading, and the section's own
+ duplicate opening heading is removed first.
+ """
+ parts: list[str] = []
+ total_chars = 0
+ for row in rows:
+ title = row["title"].strip()
+ if row["section_index"] == 0:
+ body = row["content"].lstrip()
+ else:
+ body = strip_duplicate_title_heading(row["content"], title)
+ total_chars += len(body)
+ if row["section_index"] == 0:
+ parts.append(body)
+ else:
+ parts.append(f"\n\n## {title}\n\n{body}")
+ return "".join(parts).strip() + "\n", total_chars
+
+
+def _slug(text: str, limit: int = 40) -> str:
+ slug = re.sub(r"[^\w\u4e00-\u9fff-]+", "-", text.strip())[:limit].strip("-")
+ return slug or "report"
+
+
+class SectionStore:
+ """Plugin-owned per-user section data in a deployment-local SQLite file."""
+
+ def __init__(self, path: str):
+ self.path = Path(path)
+ if not self.path.is_absolute():
+ raise ValueError("storage_path must be an absolute deployment-owned path")
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ with closing(self.connect()) as db, db:
+ db.execute(_DDL)
+
+ def connect(self) -> sqlite3.Connection:
+ db = sqlite3.connect(self.path, timeout=10)
+ db.row_factory = sqlite3.Row
+ return db
+
+ def save(self, owner: str, thread_id: str, section_index: int, title: str, content: str) -> None:
+ with closing(self.connect()) as db, db:
+ db.execute(
+ "INSERT INTO report_sections (owner, thread_id, section_index, title, content)"
+ " VALUES (?, ?, ?, ?, ?)"
+ " ON CONFLICT (owner, thread_id, section_index)"
+ " DO UPDATE SET title=excluded.title, content=excluded.content,"
+ " created_at=CURRENT_TIMESTAMP",
+ (owner, thread_id, section_index, title, content),
+ )
+
+ def load_sections(self, owner: str, thread_id: str) -> list[sqlite3.Row]:
+ with closing(self.connect()) as db:
+ return db.execute(
+ "SELECT section_index, title, content FROM report_sections WHERE owner=? AND thread_id=? AND section_index<>? ORDER BY section_index",
+ (owner, thread_id, ASSEMBLE_INDEX),
+ ).fetchall()
+
+ def assemble(self, owner: str, thread_id: str) -> tuple[str, str, int, int] | None:
+ """Return ``(filename, document, section_count, total_chars)`` or None when empty."""
+ rows = self.load_sections(owner, thread_id)
+ if not rows:
+ return None
+ document, total_chars = assemble_document(rows)
+ first = rows[0]
+ topic = _slug(first["title"]) if first["section_index"] == 0 else "report"
+ filename = f"report-{topic}.md"
+ return filename, document, len(rows), total_chars
diff --git a/examples/deerflow-extension-report-sections/pyproject.toml b/examples/deerflow-extension-report-sections/pyproject.toml
new file mode 100644
index 0000000000..88884406c9
--- /dev/null
+++ b/examples/deerflow-extension-report-sections/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "deerflow-extension-report-sections"
+version = "0.1.0"
+description = "Sectioned long-form report writing with assembled artifact delivery for DeerFlow"
+requires-python = ">=3.12"
+license = "MIT"
+dependencies = ["deerflow-extension-api>=0.2.8,<0.3"]
+
+[project.entry-points."deerflow.extensions"]
+report-sections = "deerflow_extension_report_sections:install"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["deerflow_extension_report_sections"]
diff --git a/frontend/src/core/extensions/catalog.ts b/frontend/src/core/extensions/catalog.ts
index fa1d3c8056..622ffd4eb1 100644
--- a/frontend/src/core/extensions/catalog.ts
+++ b/frontend/src/core/extensions/catalog.ts
@@ -35,6 +35,11 @@ export const extensionCatalog = [
package: "deerflow-extension-workflows",
namespace: "community.workflows",
},
+ {
+ key: "reportSections",
+ package: "deerflow-extension-report-sections",
+ namespace: "community.report-sections",
+ },
{
key: "retrieval",
package: "deerflow-extension-retrieval",
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index 562d5f06f3..1a3b7c635b 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -45,6 +45,11 @@ export const enUS: Translations = {
description:
"Save versioned Agent workflows, resume within the same Gateway process and review exact retained output versions. No restart replay or hard monetary budget.",
},
+ reportSections: {
+ title: "Sectioned long-form writing",
+ description:
+ "Write long reports section by section and assemble them into a downloadable Markdown document.",
+ },
agentTeams: {
title: "Agent teams",
description:
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index 8c93d73e1f..ae35c9ac7e 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -28,6 +28,7 @@ export interface Translations {
installationGuide: string;
catalog: Record<
| "workflows"
+ | "reportSections"
| "agentTeams"
| "bookmarks"
| "context"
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index 5d44688b7e..048bf636bd 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -45,6 +45,10 @@ export const zhCN: Translations = {
description:
"保存版本化 Agent 工作流,在同一 Gateway 进程内恢复,并确认确切的保留输出版本。不支持重启回放或硬金额预算。",
},
+ reportSections: {
+ title: "分段长文写作",
+ description: "逐段撰写长报告,并将各章节汇总为可下载的 Markdown 文档。",
+ },
agentTeams: {
title: "Agent 团队",
description: "让完整 Custom Agent 通过 @成员、共享记录与任务交接协作。",