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 '## <title>'), 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 <willem.jiang@gmail.com> --- .../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 `# <title>` while the assembler +already emits `## <title>`), 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-<topic>.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 ``# <title>``; the assembler + already emits ``## <title>`` 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 ``## <title>`` 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 通过 @成员、共享记录与任务交接协作。",