feat(extensions): show package-owned usage in extension details (#8031)

* feat(extensions): show package-owned usage in extension details

* test(extensions): align bookmark checks with detail UI

* fix(extensions): reconcile package documentation and locale fallbacks

* fix(extensions): align discovery locale fallback

* fix(extensions): isolate malformed optional documentation

* docs: keep backend guidance within inherited budget
This commit is contained in:
Wenchao An 2026-11-10 18:41:27 +08:00 committed by GitHub
parent 72fb20d56d
commit 6fe87b65fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 2714 additions and 271 deletions

View File

@ -2532,6 +2532,9 @@ knowledge service; artifacts do not automatically enable citations. It also appe
in the extension catalog with localized installation-guide metadata.
Reopening a bookmark resolves the conversation's current agent through the host, so
custom-agent conversations retain their original chat entry point, including older bookmarks.
In **Capabilities → Extensions**, an enabled extension’s detail page links directly to its registered workspace pages. The page also includes usage guidance and keeps package details under **Technical details**.
Extension packages can provide localized purpose and usage steps in `deerflow-extension.json`, including optional prerequisites. The same detail page supports background extensions without a browser module. All bundled examples include this description; see the [authoring contract](examples/README.md#describe-the-extension).
The independent [Agent teams example](examples/deerflow-extension-agent-teams/README.md)
lets full Custom Agents collaborate through native `@` mentions, shared messages and
asynchronous peer requests, with a separate team page and persistent member conversations.

View File

@ -295,9 +295,8 @@ Interaction/HITL rules: [policy](docs/RUN_INTERACTION_POLICY.md).
### Context Summarization
Automatic conversation summarization when approaching token limits, configured
under the `summarization` key in `config.yaml` (triggers: tokens, messages, or
fraction of max input); recent messages are kept while older ones are summarized.
`summarization` in `config.yaml` triggers on tokens, messages or max-input fraction.
Keep recent messages; summarize older ones.
- Manual compaction uses `POST /api/threads/{id}/compact`, reuses the same `DeerFlowSummarizationMiddleware`, writes a new checkpoint with updated `messages`/`summary_text`, and bumps only those channel versions. The route uses the shared `reserve_checkpoint_write()` boundary (also used by manual state updates); its short-lived `checkpoint_write` operation shares the durable active-thread uniqueness constraint with run admission, preventing worker-local and cross-worker checkpoint-write races.
- Cache only first-candidate no-ops; never suppress primary retries with fallback results. See [reuse and telemetry](docs/summarization.md#reuse-and-telemetry) for cache and counter contracts.

View File

@ -70,6 +70,7 @@ async def list_plugins(request: Request, response: Response):
principal = _principal(request)
response.headers["Cache-Control"] = "private, no-store"
entries = []
documentation = dict(request.app.state.extensions.documentation)
for source, plugin in request.app.state.extensions.plugins:
settings = plugin_settings(source, plugin)
module = plugin.frontend
@ -93,8 +94,36 @@ async def list_plugins(request: Request, response: Response):
"transport": transport,
"settings": {key: settings[key] for key in public},
"backend_actions": [action.name for action in plugin.backend],
"model_tools": [tool.name for tool in plugin.tools],
"documentation": documentation.get(source) if documentation.get(source, {}).get("id") == plugin.namespace else None,
}
)
registered_sources = {source for source, _ in request.app.state.extensions.plugins}
known_ids = {entry["namespace"] for entry in entries}
for source, page in request.app.state.extensions.documentation:
if source in registered_sources or page["id"] in known_ids:
continue
locales = page["locales"]
copy = next((value for tag, value in locales.items() if tag.lower() == "en-us"), None)
if copy is None:
copy = next((value for tag, value in locales.items() if tag.partition("-")[0] == "en"), next(iter(locales.values())))
entries.append(
{
"namespace": page["id"],
"title": copy["title"],
"description": copy["description"],
"viewer_id": principal.user_id,
"module": None,
"entry": None,
"transport": None,
"settings": {"enabled": True},
"backend_actions": [],
"model_tools": [],
"documentation": page,
"runtime_only": True,
}
)
known_ids.add(page["id"])
return entries

View File

@ -1,5 +1,14 @@
### Python Extension System (Runtime and Distribution)
Optional `deerflow-extension.json` is public package documentation, read beside an
already-resolved entrypoint only after successful registration. Validate and project
known fields at startup; never inspect private config or import disabled plugins for
copy. Missing files preserve legacy behavior; invalid files add a safe warning.
`LoadedExtensions.documentation` carries the snapshot into discovery, including
registered backend extensions without browser/full-stack contributions. Requirements
are optional. The package checker and bundled-catalog generator share the validator.
Tests: `test_extension_documentation.py`; authoring: `examples/README.md`.
`registry.knowledge_provider(name, provider)` (extension-api 0.2.7) adds an
operator-installed read-only `deerflow.knowledge.RetrievalProvider`. The host
validates advertised async operations, attributes entries and rolls them back

View File

@ -0,0 +1,78 @@
"""Public, package-owned documentation. Never inspect deployment config."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from urllib.parse import urlsplit
FILENAME = "deerflow-extension.json"
MAX_BYTES = 65536
def validate_documentation(value: object) -> dict:
"""Validate v1; requirements and documentation links are optional."""
def text(value: object) -> bool:
return isinstance(value, str) and bool(value.strip()) and len(value) <= 4000
if not isinstance(value, dict) or type(value.get("schema_version")) is not int or value["schema_version"] != 1:
raise ValueError("documentation requires schema_version 1")
if not isinstance(value.get("id"), str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,127}", value["id"]):
raise ValueError("documentation requires a stable id")
locales = value.get("locales")
if not isinstance(locales, dict) or not locales or len(locales) > 20:
raise ValueError("documentation requires at least one locale")
result = {"schema_version": 1, "id": value["id"], "locales": {}}
for locale, copy in locales.items():
if not re.fullmatch(r"[a-z]{2}(?:-[A-Za-z]{2,8})?", locale) or not isinstance(copy, dict):
raise ValueError("invalid documentation locale")
if not text(copy.get("title")) or not text(copy.get("description")):
raise ValueError("documentation title and description are required")
steps = copy.get("usage")
if not isinstance(steps, list) or not 1 <= len(steps) <= 12:
raise ValueError("documentation requires usage steps")
if any(not isinstance(step, dict) or not text(step.get("title")) or not text(step.get("description")) for step in steps):
raise ValueError("usage steps require a title and description")
public = {"title": copy["title"], "description": copy["description"], "usage": [{"title": s["title"], "description": s["description"]} for s in steps]}
requirements = copy.get("requirements")
if requirements is not None:
if not isinstance(requirements, list) or len(requirements) > 12 or any(not text(item) for item in requirements):
raise ValueError("requirements must be a list of nonempty descriptions")
if requirements:
public["requirements"] = requirements
result["locales"][locale] = public
if "docs_url" in value:
url = value["docs_url"]
parsed = urlsplit(url) if isinstance(url, str) else None
if not parsed or parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("docs_url must be a public HTTPS URL")
# urlsplit defers numeric/range validation until port is accessed.
_ = parsed.port
result["docs_url"] = url
# Validate only the public projection; unknown/private package fields are
# deliberately excluded and must not affect discovery.
try:
json.dumps(result, ensure_ascii=False).encode("utf-8")
except UnicodeEncodeError as exc:
raise ValueError("documentation must contain valid UTF-8 text") from exc
return result
def read_documentation(source: str) -> dict | None:
"""Read beside the already imported entrypoint; never import a disabled plugin."""
module = sys.modules.get(source.partition(":")[0])
filename = getattr(module, "__file__", None)
if not filename:
return None
path = Path(filename).parent / FILENAME
try:
with path.open("rb") as stream:
raw = stream.read(MAX_BYTES + 1)
except FileNotFoundError:
return None
if len(raw) > MAX_BYTES:
raise ValueError("extension documentation exceeds 64 KiB")
return validate_documentation(json.loads(raw))

View File

@ -10,12 +10,13 @@ from __future__ import annotations
import logging
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Any, Literal
from deerflow_extension_api import API_VERSION
from pydantic import BaseModel, ConfigDict, Field, model_validator
from deerflow.extensions.documentation import read_documentation
from deerflow.extensions.host_access import HostAccess, bind_host_access
from deerflow.extensions.model_access import ModelInvocationScope
from deerflow.extensions.registry import ExtensionRegistry, LoadedExtensions
@ -169,6 +170,7 @@ def load_extensions(specs: Sequence[ExtensionSpec]) -> tuple[LoadedExtensions, l
registry = ExtensionRegistry()
diagnostics: list[Diagnostic] = []
loaded_sources: list[str] = []
documentation: list[tuple[str, dict]] = []
for spec in specs:
if spec.table_prefix:
@ -263,6 +265,15 @@ def load_extensions(specs: Sequence[ExtensionSpec]) -> tuple[LoadedExtensions, l
continue
loaded_sources.append(spec.use)
if registry.mark() != mark:
try:
page = read_documentation(spec.use)
if page is not None:
documentation.append((spec.use, page))
except (ValueError, OSError, TypeError, RecursionError):
# Bad optional copy must not disable a working legacy extension.
diagnostics.append(Diagnostic.warning(spec.use, "Extension documentation is invalid."))
logger.warning("Extension %s: invalid documentation", spec.use)
# Loading third-party code is exactly the event an operator needs positive
# confirmation of, and every other branch here is failure-only — so without
@ -277,7 +288,7 @@ def load_extensions(specs: Sequence[ExtensionSpec]) -> tuple[LoadedExtensions, l
# every deployment, and an unconditional line would be pure boot noise.
logger.debug("No extensions configured")
return registry.build(), diagnostics
return replace(registry.build(), documentation=tuple(documentation)), diagnostics
def _frozen_config(config: dict[str, Any]) -> Mapping[str, Any]:

View File

@ -57,6 +57,7 @@ class LoadedExtensions:
routers: tuple[tuple[str, Any], ...] = ()
plugins: tuple[tuple[str, PluginContribution], ...] = ()
knowledge_providers: tuple[tuple[str, tuple[str, Any]], ...] = ()
documentation: tuple[tuple[str, dict], ...] = ()
# Precomputed attributes, not methods: hook sites read one attribute to
# short-circuit, so the zero-extension path constructs nothing.

View File

@ -43,6 +43,22 @@ def test_packaged_entry_is_checked_without_importing_plugin(tmp_path):
checker.check_wheel(tmp_path, wheel(tmp_path))
def test_documentation_must_survive_packaging(tmp_path):
package = tmp_path / "src/example"
package.mkdir(parents=True)
(package / "deerflow-extension.json").write_text("{}")
with pytest.raises(ValueError, match="documentation is not packaged"):
checker.check_wheel(tmp_path, wheel(tmp_path))
def test_packaged_documentation_requires_usage(tmp_path):
page = {"schema_version": 1, "id": "example.helper", "locales": {"en-US": {"title": "Helper", "description": "Does useful work."}}}
with pytest.raises(ValueError, match="usage"):
checker.check_wheel(tmp_path, wheel(tmp_path, files={"example/deerflow-extension.json": json.dumps(page)}))
page["locales"]["en-US"]["usage"] = [{"title": "Start a task", "description": "Works automatically."}]
checker.check_wheel(tmp_path, wheel(tmp_path, files={"example/deerflow-extension.json": json.dumps(page)}))
def test_backend_only_wheel_skips_extraction(tmp_path, monkeypatch):
def fail_if_extracted(*args, **kwargs):
pytest.fail("Backend-only wheels must not be extracted")

View File

@ -0,0 +1,224 @@
"""Package-owned copy survives backend-only discovery without importing disabled code."""
import copy
import json
import runpy
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
import pytest
from deerflow_extension_api.auth import EXTENSION_PRINCIPAL_RESOLVER_KEY, ExtensionPrincipal
from deerflow_extension_api.plugins import PluginContribution
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.routers.plugins import router
from deerflow.extensions.documentation import FILENAME, validate_documentation
from deerflow.extensions.loader import ExtensionSpec, load_extensions
from extension_test_fixtures import demo_extensions
SOURCE = "extension_test_fixtures.demo_extensions:install_ok"
PAGE = {
"schema_version": 1,
"id": "example.background",
"locales": {"en-US": {"title": "Background helper", "description": "Keeps tool statistics.", "usage": [{"title": "Run a task", "description": "Statistics are collected automatically."}]}},
}
@pytest.fixture
def package(tmp_path, monkeypatch):
monkeypatch.setattr(demo_extensions, "__file__", str(tmp_path / "__init__.py"))
path = tmp_path / FILENAME
path.write_text(json.dumps(PAGE))
return path
def test_requirements_are_optional_and_empty_requirements_are_omitted():
assert "requirements" not in validate_documentation(PAGE)["locales"]["en-US"]
value = copy.deepcopy(PAGE)
value["locales"]["en-US"]["requirements"] = []
assert "requirements" not in validate_documentation(value)["locales"]["en-US"]
@pytest.mark.parametrize("field", ["title", "description", "usage"])
def test_new_documentation_requires_meaningful_copy(field):
value = copy.deepcopy(PAGE)
del value["locales"]["en-US"][field]
with pytest.raises(ValueError):
validate_documentation(value)
@pytest.mark.parametrize("url", ["javascript:alert(1)", "http://example.org", "https://secret@example.org", "https://example.org:bad/docs", "https://example.org:65536/docs", "https://["])
def test_documentation_links_are_public_https(url):
with pytest.raises(ValueError):
validate_documentation({**PAGE, "docs_url": url})
def test_loader_projects_only_public_documentation(package):
value = copy.deepcopy(PAGE)
value["private_config"] = {"key": "synthetic-secret"}
package.write_text(json.dumps(value))
loaded, diagnostics = load_extensions([ExtensionSpec(use=SOURCE, config={"token": "private"})])
assert diagnostics == []
assert loaded.documentation == ((SOURCE, PAGE),)
# Reads use the loaded snapshot, not mutable files or config on request threads.
package.unlink()
app = FastAPI()
app.state.extensions = loaded
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda request: ExtensionPrincipal("viewer"))
app.include_router(router)
with TestClient(app) as client:
response = client.get("/api/plugins")
assert response.status_code == 200
assert response.headers["cache-control"] == "private, no-store"
(entry,) = response.json()
assert entry["namespace"] == PAGE["id"]
assert entry["documentation"] == PAGE
assert entry["runtime_only"] is True
assert entry["module"] is None
assert entry["settings"] == {"enabled": True}
assert "private" not in response.text
@pytest.mark.parametrize(
"tags,expected",
[
(["zh-CN", "en"], "English"),
(["zh-CN", "en-us"], "English"),
(["zh-CN", "en-GB"], "English"),
(["zh-CN", "en", "en-us"], "US English"),
(["zh-CN", "fr"], "中文"),
],
)
def test_backend_only_discovery_prefers_english_language_variants(package, tags, expected):
page = copy.deepcopy(PAGE)
page["locales"] = {}
titles = {"zh-CN": "中文", "fr": "Français"}
if len(tags) == 3:
titles["en-us"] = "US English"
for tag in tags:
localized = copy.deepcopy(PAGE["locales"]["en-US"])
localized["title"] = titles.get(tag, "English")
page["locales"][tag] = localized
package.write_text(json.dumps(page))
loaded, diagnostics = load_extensions([ExtensionSpec(use=SOURCE)])
assert not diagnostics
app = FastAPI()
app.state.extensions = loaded
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda request: ExtensionPrincipal("viewer"))
app.include_router(router)
with TestClient(app) as client:
response = client.get("/api/plugins")
assert response.status_code == 200
(entry,) = response.json()
assert entry["title"] == expected
assert entry["documentation"] == page
def test_bad_documentation_does_not_disable_working_extension(package):
package.write_text('{"secret":"synthetic-secret"}')
loaded, diagnostics = load_extensions([ExtensionSpec(use=SOURCE)])
assert loaded.has_middleware_contributors
assert loaded.documentation == ()
assert [(item.level, item.source, item.message) for item in diagnostics] == [("warning", SOURCE, "Extension documentation is invalid.")]
@pytest.mark.parametrize("field", ["id", "locale", "title", "description", "usage_title", "usage_description", "requirements", "docs_url"])
@pytest.mark.parametrize("surrogate", ["\ud800", "\udfff"])
def test_unencodable_public_documentation_cannot_break_discovery(package, field, surrogate):
page = copy.deepcopy(PAGE)
localized = page["locales"]["en-US"]
if field == "id":
page["id"] = surrogate
elif field == "locale":
page["locales"] = {surrogate: localized}
elif field.startswith("usage_"):
localized["usage"][0][field.removeprefix("usage_")] = surrogate
elif field == "requirements":
localized["requirements"] = [surrogate]
elif field == "docs_url":
page["docs_url"] = "https://example.org/" + surrogate
else:
localized[field] = surrogate
package.write_text(json.dumps(page))
loaded, diagnostics = load_extensions([ExtensionSpec(use=SOURCE)])
assert loaded.has_middleware_contributors
healthy = PluginContribution(namespace="community.bookmarks", title="Bookmarks", enabled=True)
app = FastAPI()
app.state.extensions = replace(loaded, plugins=(("healthy:install", healthy),))
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda request: ExtensionPrincipal("viewer"))
app.include_router(router)
with TestClient(app) as client:
response = client.get("/api/plugins")
assert response.status_code == 200
assert [entry["namespace"] for entry in response.json()] == [healthy.namespace]
assert loaded.documentation == ()
assert [(item.level, item.source, item.message) for item in diagnostics] == [("warning", SOURCE, "Extension documentation is invalid.")]
def test_unicode_documentation_and_ignored_private_fields_remain_valid():
page = copy.deepcopy(PAGE)
page["locales"]["en-US"]["title"] = "指南 🦌"
page["docs_url"] = "https://example.org:8443/docs"
expected = copy.deepcopy(page)
page["private_config"] = {"ignored": "\ud800"}
assert validate_documentation(page) == expected
def test_missing_documentation_keeps_legacy_behavior(package):
package.unlink()
loaded, diagnostics = load_extensions([ExtensionSpec(use=SOURCE)])
assert loaded.has_middleware_contributors
assert not loaded.documentation and not diagnostics
def test_disabled_or_failed_install_cannot_publish_documentation(package):
for spec in [ExtensionSpec(use=SOURCE, enabled=False), ExtensionSpec(use=SOURCE.replace("install_ok", "install_partial_then_raise"))]:
loaded, _ = load_extensions([spec])
assert not loaded.documentation
assert not loaded.has_middleware_contributors
def test_bundled_documentation_snapshot_is_current():
root = Path(__file__).resolve().parents[2]
script = runpy.run_path(str(root / "scripts/sync_extension_documentation.py"))
assert script["DESTINATION"].read_text(encoding="utf-8") == script["render"]()
assert len(json.loads(script["render"]())) == 15
@pytest.mark.parametrize("collision", [False, True])
def test_plugin_discovery_fences_documentation_identity(collision):
plugin = PluginContribution(namespace="example.background", title="Plugin", description="Plugin copy")
page = copy.deepcopy(PAGE)
page["id"] = plugin.namespace if collision else "another.plugin"
source = "backend.only:install" if collision else SOURCE
app = FastAPI()
app.state.extensions = SimpleNamespace(plugins=((SOURCE, plugin),), documentation=((source, page),))
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda request: ExtensionPrincipal("viewer"))
app.include_router(router)
with TestClient(app) as client:
response = client.get("/api/plugins")
assert response.status_code == 200
(entry,) = response.json()
assert entry["namespace"] == plugin.namespace
assert entry["documentation"] is None
def test_bundled_generator_rejects_missing_language_and_shared_size_limit(tmp_path, monkeypatch):
root = Path(__file__).resolve().parents[2]
script = runpy.run_path(str(root / "scripts/sync_extension_documentation.py"))
namespace = script["render"].__globals__
monkeypatch.setitem(namespace, "ROOT", tmp_path)
path = tmp_path / "examples/deerflow-extension-test/package/deerflow-extension.json"
path.parent.mkdir(parents=True)
path.write_text(json.dumps(PAGE))
with pytest.raises(ValueError, match="English and Chinese"):
script["render"]()
value = copy.deepcopy(PAGE)
value["locales"]["zh"] = copy.deepcopy(value["locales"]["en-US"])
path.write_text(json.dumps(value))
assert "deerflow-extension-test" in script["render"]()
monkeypatch.setitem(namespace, "MAX_BYTES", 1)
with pytest.raises(ValueError, match="too large"):
script["render"]()

View File

@ -435,15 +435,20 @@ visiting an unavailable page directly does not mount a plugin.
Inline modules use authenticated fetch plus a released Blob URL. Manifest assets use
native credentialed module scripts, preserving relative imports and resource URLs.
Both honor the backend base and prefixes; transport and cache semantics are documented in
`docs/full-stack-plugins.md`. Host copy belongs in the typed locale dictionaries.
`docs/full-stack-plugins.md`. Host UI labels belong in the typed locale dictionaries;
package purpose and usage come from `deerflow-extension.json` (see the
[authoring contract](../examples/README.md#describe-the-extension)). Installed copy
takes precedence over the generated bundled snapshot; omit empty prerequisites.
Conversation action factories, shapes and availability callbacks are guarded per plugin;
only validated value snapshots reach the toolbar/sidebar render paths.
`PluginNavigation` and the dynamic workspace extension route consume page declarations;
Capability Center defaults to the repository examples in `core/extensions/catalog.ts`,
merged by explicit namespace with runtime descriptors. Catalog-only entries are discovery
metadata, never module-loader inputs or proof of installation. Backend-only examples may
have no plugin descriptor; keep their runtime status unasserted. Details link to package
installation instructions. Keep the catalog aligned with `examples/deerflow-extension-*`.
metadata, never module-loader inputs or proof of installation. Documented backend
extensions with successful registrations have runtime-only descriptors without browser
modules. Other catalog-only entries retain an unknown runtime status. Details show usage
steps and optional documentation links; launch links require actual enabled page
registrations through `pluginPages`. Keep the catalog aligned with `examples/deerflow-extension-*`.
Agent teams uses `community.agent-teams`; merge its installed descriptor into the
localized catalog entry without asserting runtime status for a catalog-only row.
Conversation action slots augment normal/custom-agent toolbars and sidebar menus without replacing native export or notification.

View File

@ -14,6 +14,47 @@ Additional examples cover [text-list classification](deerflow-extension-jev-clas
[advisory screening of fetched content](deerflow-extension-jev-screening/README.md), and
[sectioned long-form report writing](deerflow-extension-report-sections/README.md).
## Describe the extension
Package `deerflow-extension.json` beside the entrypoint's Python module (beside
`__init__.py` for a package). Capability Center renders this public description
without requiring a browser module. Use the full-stack plugin namespace as `id`,
or a unique stable ID for a backend-only extension. A v1 document requires an ID,
at least one locale, and a nonempty **title**, **description** (purpose) and
**usage** list in every supplied locale. `requirements` and `docs_url` are optional;
omit requirements when none apply. Keep secrets and deployment configuration out
of this file. Names, purposes and instructions are authored by the package;
loaded/enabled state and registered tools come from the host.
```json
{
"schema_version": 1,
"id": "company.helper",
"locales": {
"en-US": {
"title": "Task statistics",
"description": "Collect tool-call statistics during tasks.",
"usage": [
{"title": "Run a task", "description": "Statistics are collected automatically after activation."}
]
}
}
}
```
The packaging checker validates this file when present and detects omitted wheel
resources. Older packages without it remain compatible. Invalid optional copy
produces a startup diagnostic while the working extension remains available.
Gateway reads copy at startup only, after successful registration. Disabled and
failed packages are not imported for documentation; a catalog entry alone does
not prove activation. A documented backend extension with actual registrations
appears in discovery even if it supplies no full-stack plugin.
For bundled examples, update their package files and run
`python scripts/sync_extension_documentation.py` from the repository root. The
generated frontend snapshot supplies descriptions before installation; installed
package copy takes precedence. Backend tests check that the snapshot is current.
## Edit, test, check, then install
1. Copy the selected example outside the DeerFlow checkout. Rename its Python

View File

@ -0,0 +1,49 @@
{
"schema_version": 1,
"id": "community.agent-teams",
"locales": {
"zh-CN": {
"title": "Agent 团队",
"description": "让多个 Agent 围绕同一个目标协作,共享进展,交接任务。",
"usage": [
{
"title": "组建你的团队",
"description": "选择 2–8 个已有的自定义 Agent,给团队命名,并设定共同目标。"
},
{
"title": "用 @ 分配任务",
"description": "在团队对话中输入 @ 并选择成员,再发送任务;成员会在各自的会话中处理。"
},
{
"title": "查看进展与成果",
"description": "在活动记录中查看成员回复和交接;展开任务可打开成员会话、处理提问或下载成果。"
}
],
"requirements": [
"至少准备 2 个自定义 Agent;如限制了工具分组,需要允许 extensions。"
]
},
"en-US": {
"title": "Agent teams",
"description": "Bring Agents together around a shared goal, share progress and hand off tasks.",
"usage": [
{
"title": "Build your team",
"description": "Choose 2–8 existing Custom Agents, name your team and set a shared goal."
},
{
"title": "Delegate with @mentions",
"description": "Type @ and select members in the team composer, then send a task to their own conversations."
},
{
"title": "Review progress and results",
"description": "Read replies and handoffs in the activity feed. Expand a task to open its conversation, answer questions or download results."
}
],
"requirements": [
"Prepare at least two Custom Agents; allow the extensions tool group if using a tool-group allowlist."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-agent-teams#readme"
}

View File

@ -0,0 +1,35 @@
{
"schema_version": 1,
"id": "community.bookmarks",
"locales": {
"zh-CN": {
"title": "会话书签",
"description": "收藏有用的回答,在独立页面查找与整理自己的书签。",
"usage": [
{
"title": "收藏有用的回答",
"description": "在已有回答的会话菜单中,选择“书签 → 保存最后一条回答”。"
},
{
"title": "查找与整理",
"description": "打开“我的书签”,按文字搜索、修改名称,或回到原会话查看上下文。"
}
]
},
"en-US": {
"title": "Bookmarks",
"description": "Save useful answers and find and organize your own bookmarks on a dedicated page.",
"usage": [
{
"title": "Save a useful answer",
"description": "In a conversation with an answer, choose Bookmarks → Save last answer from its menu."
},
{
"title": "Find and organize",
"description": "Open My bookmarks to search saved text, rename entries or return to their source conversations."
}
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks#readme"
}

View File

@ -0,0 +1,35 @@
{
"schema_version": 1,
"id": "catalog/example",
"locales": {
"zh-CN": {
"title": "扩展开发示例",
"description": "演示中间件、任务生命周期、模型观察器、服务和 HTTP 路由的扩展方式。",
"usage": [
{
"title": "安装到开发环境",
"description": "参考包内文档安装示例,配置并重启 Gateway。它用于演示扩展开发接口。"
},
{
"title": "运行任务并检查统计",
"description": "执行包含工具调用的任务,再通过示例统计 API 查看调用次数、结果类型与耗时。"
}
]
},
"en-US": {
"title": "Extension development example",
"description": "Explore middleware, task lifecycle, model observers, services and HTTP route contributions.",
"usage": [
{
"title": "Install in a development environment",
"description": "Follow the package guide to install, configure and restart Gateway. This package demonstrates extension development contracts."
},
{
"title": "Run a task and inspect statistics",
"description": "Run a task that uses tools, then inspect call counts, outcome types and durations through the example statistics API."
}
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-example#readme"
}

View File

@ -0,0 +1,41 @@
{
"schema_version": 1,
"id": "community.jev-classify",
"locales": {
"zh-CN": {
"title": "文本分类",
"description": "按给定类别为文本列表打标签,使用部署方配置的分类服务。",
"usage": [
{
"title": "提供文本和分类标准",
"description": "在对话中提供待分类的文本列表,说明各类别的含义和边界;也可上传表格并指出文本列。"
},
{
"title": "让 Agent 执行分类",
"description": "要求使用文本分类工具,检查返回标签;如需表格,让 Agent 将标签写回输出文件。"
}
],
"requirements": [
"管理员已配置 Jev 或兼容聊天接口的分类服务;待分类文本会发送给该服务。"
]
},
"en-US": {
"title": "Text classification",
"description": "Label a list of texts with supplied categories using the deployment-configured classification service.",
"usage": [
{
"title": "Provide texts and categories",
"description": "Give the Agent a list of texts and explain the categories, or upload a table and identify its text column."
},
{
"title": "Ask the Agent to classify",
"description": "Ask it to use the text classification tool and inspect the labels. For a table, ask it to write labels into an output file."
}
],
"requirements": [
"An administrator must configure Jev or a compatible chat endpoint. Texts to classify are sent to that service."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-jev-classify#readme"
}

View File

@ -0,0 +1,41 @@
{
"schema_version": 1,
"id": "community.jev-context",
"locales": {
"zh-CN": {
"title": "上下文裁剪",
"description": "按需缩短旧的只读工具结果,保留近期消息与原生摘要。",
"usage": [
{
"title": "正常进行对话",
"description": "启用后自动检查长对话中的旧工具输出,不需要打开独立页面或输入额外命令。"
},
{
"title": "达到阈值时自动处理",
"description": "满足配置的上下文阈值和调用间隔后,裁剪已过时的只读工具结果,保留近期消息和受保护内容。"
}
],
"requirements": [
"管理员已配置 TypeSafe Jev 服务凭据及触发阈值;处理时会向该服务发送限定范围的对话内容。"
]
},
"en-US": {
"title": "Context pruning",
"description": "Shorten older read-only tool results while preserving recent messages and native summaries.",
"usage": [
{
"title": "Continue your conversation",
"description": "Once enabled, it automatically checks old tool output in long conversations. No separate page or command is needed."
},
{
"title": "Prune when the threshold is reached",
"description": "After the configured token threshold and call interval, obsolete read-only results are shortened while recent and protected content is retained."
}
],
"requirements": [
"An administrator must configure TypeSafe Jev credentials and thresholds. Processing sends bounded conversation content to that service."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-jev-context#readme"
}

View File

@ -0,0 +1,41 @@
{
"schema_version": 1,
"id": "catalog/screening",
"locales": {
"zh-CN": {
"title": "内容风险提示",
"description": "检查获取的外部内容,为可疑工具结果添加提示,不阻断工具执行。",
"usage": [
{
"title": "按平常方式搜索与读取",
"description": "启用后,在 Agent 获取网页、搜索或 MCP 工具结果时自动检查部分内容。"
},
{
"title": "结合提示判断内容",
"description": "检测到可疑指令时,会在工具结果中增加风险提示。它提供提醒,不阻断工具,也不保证识别所有风险。"
}
],
"requirements": [
"管理员已配置 TypeSafe Jev 服务;待检查的内容片段会发送给该服务。"
]
},
"en-US": {
"title": "Content risk warnings",
"description": "Screen fetched content and add advisory warnings to suspicious tool results without blocking tools.",
"usage": [
{
"title": "Search and read as usual",
"description": "Once enabled, the middleware checks bounded excerpts of fetched pages, search results and MCP tool output."
},
{
"title": "Review advisory warnings",
"description": "Suspicious instructions add a warning to the tool result. Screening is advisory; it does not block tools or guarantee detection."
}
],
"requirements": [
"An administrator must configure TypeSafe Jev. Content excerpts are sent to that service for screening."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-jev-screening#readme"
}

View File

@ -0,0 +1,49 @@
{
"schema_version": 1,
"id": "knowledge-center",
"locales": {
"zh-CN": {
"title": "知识中心",
"description": "在独立页面浏览知识空间、导入资料,并查看文档处理状态。",
"usage": [
{
"title": "选择空间与知识库",
"description": "打开知识中心,选择你有权限访问的空间及其中的知识库。"
},
{
"title": "导入资料",
"description": "按页面提供的操作导入文件、网址或文本。可用按钮取决于你的权限和服务支持。"
},
{
"title": "查看处理状态",
"description": "检查返回的文档记录或按文档标识查询状态,必要时手动刷新。"
}
],
"requirements": [
"已安装并启用知识空间与连接、知识入库与处理扩展,并为你分配可访问的空间和资源。"
]
},
"en-US": {
"title": "Knowledge center",
"description": "Browse knowledge spaces, import content and inspect document processing on a dedicated page.",
"usage": [
{
"title": "Choose a space and knowledge base",
"description": "Open Knowledge center and select an accessible space and mapped knowledge base."
},
{
"title": "Import content",
"description": "Use the available controls to import a file, URL or text. Controls depend on your permissions and provider support."
},
{
"title": "Inspect processing",
"description": "Inspect the returned document record or look up a document by ID, refreshing its status when needed."
}
],
"requirements": [
"Enable the knowledge platform and management extensions and grant you access to spaces and resources."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-knowledge-center#readme"
}

View File

@ -0,0 +1,41 @@
{
"schema_version": 1,
"id": "knowledge-management",
"locales": {
"zh-CN": {
"title": "知识入库与处理",
"description": "导入文件、网址和文本,并查询、管理文档处理任务。",
"usage": [
{
"title": "连接已有管理入口",
"description": "由管理员通过知识中心或管理 API,选择已授权的知识库与操作。此扩展本身提供后台接口。"
},
{
"title": "导入并查询处理结果",
"description": "提交文件、网址或文本,保留返回的文档标识,再查询处理状态;可用操作由上游能力和权限共同决定。"
}
],
"requirements": [
"管理员已配置 WeKnora 管理连接及明确的用户、知识库和操作授权。"
]
},
"en-US": {
"title": "Knowledge ingestion and processing",
"description": "Import files, URLs and text, then inspect and manage document processing.",
"usage": [
{
"title": "Use an existing management entry",
"description": "Use Knowledge center or the management API to select an authorized knowledge base and operation. This extension provides backend endpoints."
},
{
"title": "Ingest and inspect processing",
"description": "Submit a file, URL or text, retain the returned document ID and query its status. Available operations depend on upstream capabilities and permissions."
}
],
"requirements": [
"The administrator must configure a WeKnora management connection and explicit user, knowledge-base and operation grants."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-knowledge-management#readme"
}

View File

@ -0,0 +1,41 @@
{
"schema_version": 1,
"id": "knowledge-platform",
"locales": {
"zh-CN": {
"title": "知识空间与连接",
"description": "组织知识空间、连接与成员权限,让团队按授权范围使用资料。",
"usage": [
{
"title": "由管理员配置空间与资源",
"description": "通过平台管理接口配置连接、空间成员和可访问资源,明确操作权限。"
},
{
"title": "在授权范围内使用",
"description": "通过知识中心或平台接口选择可访问的空间和知识库,再进行导入与状态查询。"
}
],
"requirements": [
"已安装知识入库与处理扩展,并配置连接、持久化存储和空间策略。"
]
},
"en-US": {
"title": "Knowledge spaces and connections",
"description": "Organize knowledge spaces, connections and membership for authorized access.",
"usage": [
{
"title": "Configure spaces and resources",
"description": "An administrator configures connections, space membership, accessible resources and permitted operations through the platform API."
},
{
"title": "Work within an authorized space",
"description": "Use Knowledge center or platform APIs to select an accessible space and knowledge base, then ingest content or inspect processing."
}
],
"requirements": [
"Install the knowledge management extension and configure connections, persistent storage and space policies."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-knowledge-platform#readme"
}

View File

@ -0,0 +1,41 @@
{
"schema_version": 1,
"id": "catalog/knowledge",
"locales": {
"zh-CN": {
"title": "知识范围选择示例",
"description": "用示例文档演示知识库和文件范围选择、检索及来源引用。",
"usage": [
{
"title": "选择示例知识范围",
"description": "在对话的知识范围选择器中选择 Synthetic guides,可进一步选择 Synthetic knowledge note。"
},
{
"title": "提问并查看引用",
"description": "让 Agent 搜索 Cedar,检查回答中的来源引用。此插件使用公开的虚构演示文档。"
}
],
"requirements": [
"管理员已启用知识检索工具和范围选择,并将工具连接到此示例 Provider。"
]
},
"en-US": {
"title": "Knowledge scope example",
"description": "Search sample documents within selected knowledge bases or files and inspect source citations.",
"usage": [
{
"title": "Select the example scope",
"description": "Choose Synthetic guides in the chat scope selector, optionally narrowing it to Synthetic knowledge note."
},
{
"title": "Ask and inspect citations",
"description": "Ask the Agent to search for Cedar and inspect its source citations. This example uses a public synthetic document."
}
],
"requirements": [
"The administrator must enable knowledge retrieval and scope selection and connect the tool to this example provider."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-knowledge#readme"
}

View File

@ -0,0 +1,41 @@
{
"schema_version": 1,
"id": "catalog/lightrag",
"locales": {
"zh-CN": {
"title": "LightRAG",
"description": "从 LightRAG 检索文档与实体关系,并使用统一的来源引用。",
"usage": [
{
"title": "在对话中提出检索问题",
"description": "让 Agent 查询已连接知识库中的文档、实体或实体间关系。"
},
{
"title": "查看来源依据",
"description": "检查结果的文档引用;实体关系只展示服务返回的边,有文档片段支持时才提供对应引用。"
}
],
"requirements": [
"管理员已配置 LightRAG 连接、凭据、用户绑定和相关检索工具。"
]
},
"en-US": {
"title": "LightRAG",
"description": "Retrieve documents and entity relations from LightRAG with shared source citations.",
"usage": [
{
"title": "Ask a retrieval question",
"description": "Ask the Agent to search the connected knowledge base for documents, entities or relationships."
},
{
"title": "Inspect supporting sources",
"description": "Check document citations. Relations reflect returned edges and receive document citations only when supporting excerpts are available."
}
],
"requirements": [
"The administrator must configure the LightRAG connection, credentials, user bindings and retrieval tools."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-lightrag#readme"
}

View File

@ -0,0 +1,43 @@
{
"schema_version": 1,
"id": "community.report-sections",
"locales": {
"zh-CN": {
"title": "分段长文写作",
"description": "逐段撰写长报告,并将各章节汇总为可下载的 Markdown 文档。",
"usage": [
{
"title": "在对话里提出写作任务",
"description": "告诉 Agent 报告主题、目标读者和章节要求,并要求使用分段长文写作工具。"
},
{
"title": "先定大纲,再逐章完成",
"description": "让 Agent 先保存大纲,再逐章撰写和保存。需要修改时,在同一对话中指出要修改的章节。"
},
{
"title": "汇总并下载",
"description": "全部章节完成后,让 Agent 合并报告;在对话的文件成果中下载 Markdown 文档。"
}
]
},
"en-US": {
"title": "Sectioned long-form writing",
"description": "Write long reports section by section and assemble them into a downloadable Markdown document.",
"usage": [
{
"title": "Describe the report in chat",
"description": "Tell the Agent the topic, audience and chapter requirements, and ask it to use the sectioned writing tool."
},
{
"title": "Outline, then write each chapter",
"description": "Ask the Agent to save an outline, then write and save chapters one at a time. Request chapter revisions in the same conversation."
},
{
"title": "Assemble and download",
"description": "Ask the Agent to assemble the finished chapters, then download the Markdown document from the conversation artifacts."
}
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-report-sections#readme"
}

View File

@ -0,0 +1,35 @@
{
"schema_version": 1,
"id": "example.retrieval",
"locales": {
"zh-CN": {
"title": "本地检索示例",
"description": "使用固定示例文档演示只读检索与结构化工具结果,不连接外部知识库。",
"usage": [
{
"title": "在对话里试一次检索",
"description": "让 Agent 使用本地检索示例搜索 release,查看匹配到的固定演示文档。"
},
{
"title": "检查返回结果",
"description": "观察文字摘要与结构化工具成果;示例数据是虚构的,不代表真实知识库内容。"
}
]
},
"en-US": {
"title": "Local retrieval example",
"description": "Demonstrate read-only retrieval and structured tool results using fixed sample documents, without an external knowledge service.",
"usage": [
{
"title": "Try a search in chat",
"description": "Ask the Agent to use the local retrieval example to search for release and inspect the matching demonstration document."
},
{
"title": "Inspect the result",
"description": "Review its text summary and structured tool artifact. The fixed corpus is synthetic, not a real knowledge base."
}
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-retrieval#readme"
}

View File

@ -0,0 +1,41 @@
{
"schema_version": 1,
"id": "catalog/weknora",
"locales": {
"zh-CN": {
"title": "WeKnora 知识检索",
"description": "检索 WeKnora 知识库和所选文档,并展示来源引用。",
"usage": [
{
"title": "选择检索范围",
"description": "在对话中选择已授权的 WeKnora 知识库,按需缩小到具体文档。"
},
{
"title": "提问并核对来源",
"description": "提出与已入库资料相关的问题,查看返回片段和来源引用。"
}
],
"requirements": [
"已有完成索引的 WeKnora 知识库;管理员已配置连接、凭据、用户授权和检索工具。"
]
},
"en-US": {
"title": "WeKnora knowledge retrieval",
"description": "Search WeKnora knowledge bases and selected documents with source citations.",
"usage": [
{
"title": "Select a search scope",
"description": "Choose an authorized WeKnora knowledge base in chat and narrow it to specific documents when needed."
},
{
"title": "Ask and verify sources",
"description": "Ask a question about indexed content and inspect the retrieved excerpts and source citations."
}
],
"requirements": [
"An indexed WeKnora knowledge base is required, with an administrator-configured connection, credentials, user grants and retrieval tool."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-weknora#readme"
}

View File

@ -0,0 +1,49 @@
{
"schema_version": 1,
"id": "community.workflows",
"locales": {
"zh-CN": {
"title": "脚本工作流",
"description": "运行可检查的 Agent 脚本工作流,跟踪步骤进度,并按保留版本确认成果。",
"usage": [
{
"title": "打开脚本工作流",
"description": "进入工作流页面,准备一个受支持的 JavaScript 工作流及其输入参数。"
},
{
"title": "提交并查看执行过程",
"description": "启动后查看步骤进度、Agent 子会话和执行结果;需要人工确认的步骤会等待你的处理。"
},
{
"title": "按保留版本确认成果",
"description": "检查实际保留的输出版本,再确认或继续工作流。重启恢复取决于工作流和部署是否支持。"
}
],
"requirements": [
"管理员已配置可调用的 Agent 和持久化存储;Gateway 运行在 Linux 或 macOS。"
]
},
"en-US": {
"title": "Script workflows",
"description": "Run inspectable Agent workflows, track steps and review retained output versions.",
"usage": [
{
"title": "Open Script workflows",
"description": "Open the workflow page and prepare a supported JavaScript workflow and its input arguments."
},
{
"title": "Run and inspect",
"description": "Start the workflow and inspect steps, Agent conversations and results. Human checkpoints wait for your response."
},
{
"title": "Review retained output",
"description": "Inspect the retained output version before approving or continuing. Restart recovery depends on workflow and deployment compatibility."
}
],
"requirements": [
"The administrator must configure allowed Agents and persistent storage on a Linux or macOS Gateway."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-workflows#readme"
}

View File

@ -423,11 +423,10 @@ and require a known model to save a model switch.
## Full-stack plugin UI
Follow [plugin invariants](../docs/full-stack-plugins.md#frontend-implementation-invariants):
viewer-bound auth/CSRF, native actions, i18n, mount/dispose/abort fences.
Shadow DOM is not a sandbox. Bundled catalogs need typed en/zh entries, namespaces;
entries do not prove activation. Optional `requestBackend` binds viewers,
rejects redirects, never retries; routes retain authorization.
Follow [plugin invariants](../docs/full-stack-plugins.md#frontend-implementation-invariants).
Use `pluginPages` only for enabled runtime entries; catalogs never imply activation.
Package documentation and bundled-copy sync: `examples/README.md`. Hide empty requirements.
Optional `requestBackend` binds viewers, rejects redirects, never retries; routes retain authorization.
### Composer references

View File

@ -0,0 +1,239 @@
"use client";
import {
ArrowRightIcon,
BookOpenIcon,
ChevronDownIcon,
CircleAlertIcon,
CircleCheckIcon,
ExternalLinkIcon,
RefreshCwIcon,
} from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import type { ExtensionCatalogEntry } from "@/core/extensions/catalog";
import { pluginPages, pluginPageTitle } from "@/core/extensions/pages";
import { extensionIcon } from "@/core/extensions/registry";
import { useI18n } from "@/core/i18n/hooks";
export function ExtensionDetail({
entry,
discovery,
}: {
entry: ExtensionCatalogEntry;
discovery: "pending" | "error" | "ready";
}) {
const { t, locale } = useI18n();
const copy = t.extensions;
const { loaded } = entry;
const available = discovery === "ready";
const failed = available && !!loaded?.error;
const enabled = available && !failed && loaded?.settings.enabled === true;
const pages = enabled && loaded ? pluginPages([loaded]) : [];
const primaryPage = pages[0];
const Icon = extensionIcon(loaded?.extension?.icon ?? entry.icon);
const label = !available
? discovery === "pending"
? copy.loading
: copy.unavailable
: failed
? copy.moduleUnavailable
: !loaded
? copy.catalogEntry
: enabled
? loaded?.runtime_only
? copy.detail.loaded
: t.capabilities.enabled
: t.capabilities.disabled;
const hint = !available
? discovery === "pending"
? copy.loading
: copy.unavailable
: failed
? copy.detail.failedHint
: !loaded
? copy.catalogHint
: enabled
? loaded?.runtime_only
? copy.detail.loadedHint
: copy.detail.readyHint
: copy.detail.disabledHint;
return (
<article className="min-w-0 space-y-8">
<header className="bg-card rounded-2xl border p-6 sm:p-8">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-start gap-4 sm:gap-5">
<div className="bg-muted flex size-14 shrink-0 items-center justify-center rounded-2xl sm:size-16">
<Icon className="size-7 sm:size-8" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<h2 className="text-2xl font-semibold tracking-tight sm:text-[28px]">
{entry.title}
</h2>
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
enabled
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
: failed
? "bg-destructive/10 text-destructive"
: "bg-muted text-muted-foreground"
}`}
>
{enabled ? (
<CircleCheckIcon className="size-3.5" />
) : failed ? (
<CircleAlertIcon className="size-3.5" />
) : null}
{label}
</span>
</div>
<p className="text-muted-foreground mt-3 max-w-2xl text-sm leading-7">
{entry.description}
</p>
</div>
</div>
{primaryPage && (
<Button asChild size="lg" className="h-11 shrink-0 rounded-xl px-5">
<Link href={primaryPage.href}>
{copy.open(
pages.length === 1
? entry.title
: pluginPageTitle(primaryPage.surface, locale),
)}
<ArrowRightIcon />
</Link>
</Button>
)}
</div>
</header>
<div className="grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_20rem] lg:gap-12">
<section
className="min-w-0 px-1"
aria-labelledby="extension-get-started"
>
<h3 id="extension-get-started" className="text-base font-semibold">
{copy.detail.gettingStarted}
</h3>
{entry.gettingStarted?.length ? (
<ol className="mt-6 space-y-6">
{entry.gettingStarted.map((step, index) => (
<li key={index} className="flex gap-4">
<span className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-medium tabular-nums">
{String(index + 1).padStart(2, "0")}
</span>
<div className="min-w-0 pt-1">
<h4 className="text-sm font-medium">{step.title}</h4>
<p className="text-muted-foreground mt-1.5 text-sm leading-6">
{step.description}
</p>
</div>
</li>
))}
</ol>
) : !pages.length ? (
<p className="text-muted-foreground mt-4 text-sm leading-7">
{enabled ? copy.detail.noPageHint : copy.detail.guideHint}
</p>
) : null}
{pages.length > (entry.gettingStarted?.length ? 1 : 0) && (
<div className="mt-6 divide-y rounded-xl border px-4">
{pages.map(({ surface, href }) => (
<Link
key={href}
href={href}
className="hover:text-foreground text-muted-foreground flex items-center justify-between gap-3 py-4 text-sm transition-colors"
>
{pluginPageTitle(surface, locale)}
<ArrowRightIcon className="size-4 shrink-0" />
</Link>
))}
</div>
)}
</section>
<aside className="bg-muted/30 min-w-0 rounded-2xl p-6">
{!!entry.requirements?.length && (
<section
className="mb-6 border-b pb-6"
aria-labelledby="extension-requirements"
>
<h3 id="extension-requirements" className="text-sm font-semibold">
{copy.detail.requirements}
</h3>
<ul className="text-muted-foreground mt-3 list-disc space-y-2 pl-4 text-sm leading-6">
{entry.requirements.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</section>
)}
<h3 className="text-sm font-semibold">{copy.detail.management}</h3>
<p
role={failed ? "alert" : undefined}
className="text-muted-foreground mt-3 text-sm leading-6"
>
{hint}
</p>
{entry.guide && (
<a
className="hover:text-muted-foreground mt-5 inline-flex items-center gap-2 text-sm font-medium transition-colors"
href={entry.guide}
target="_blank"
rel="noopener noreferrer"
>
<BookOpenIcon className="size-4" />
{copy.detail.guide}
<ExternalLinkIcon className="size-3.5" />
</a>
)}
<details className="group mt-6 border-t pt-5">
<summary className="text-muted-foreground hover:text-foreground flex cursor-pointer list-none items-center justify-between gap-3 text-xs [&::-webkit-details-marker]:hidden">
{copy.detail.technicalDetails}
<ChevronDownIcon className="size-3.5 transition-transform group-open:rotate-180" />
</summary>
<dl className="mt-4 space-y-3 text-xs">
{!!loaded?.model_tools?.length && (
<div>
<dt className="text-muted-foreground">{copy.detail.tools}</dt>
<dd className="mt-1 font-mono break-all">
{loaded.model_tools.join(", ")}
</dd>
</div>
)}
{entry.package && (
<div>
<dt className="text-muted-foreground">
{copy.detail.package}
</dt>
<dd className="mt-1 font-mono break-all">{entry.package}</dd>
</div>
)}
<div>
<dt className="text-muted-foreground">
{copy.detail.namespace}
</dt>
<dd className="mt-1 font-mono break-all">{entry.id}</dd>
</div>
</dl>
<p className="text-muted-foreground mt-5 text-xs leading-5">
{copy.detail.refreshHint}
</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => window.location.reload()}
>
<RefreshCwIcon />
{copy.detail.refresh}
</Button>
</details>
</aside>
</div>
</article>
);
}

View File

@ -1,18 +1,8 @@
"use client";
import {
ArrowLeftIcon,
ChevronRightIcon,
CircleAlertIcon,
CircleCheckIcon,
CircleMinusIcon,
ExternalLinkIcon,
InfoIcon,
RefreshCwIcon,
} from "lucide-react";
import { ArrowLeftIcon, ChevronRightIcon, RefreshCwIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
extensionDirectory,
@ -21,8 +11,8 @@ import {
import { useFrontendExtensions } from "@/core/extensions/hooks";
import { extensionIcon } from "@/core/extensions/registry";
import { useI18n } from "@/core/i18n/hooks";
import type { Translations } from "@/core/i18n/locales/types";
import { ExtensionDetail } from "./extension-detail";
import { PluginRow } from "./plugin-directory";
function ExtensionIconTile({ entry }: { entry: ExtensionCatalogEntry }) {
@ -34,68 +24,8 @@ function ExtensionIconTile({ entry }: { entry: ExtensionCatalogEntry }) {
);
}
function ExtensionDetail({
entry,
copy,
}: {
entry: ExtensionCatalogEntry;
copy: Translations["extensions"];
}) {
const { loaded } = entry;
const notice = loaded?.error
? { icon: <CircleAlertIcon />, text: copy.moduleUnavailable }
: !loaded
? { icon: <InfoIcon />, text: copy.catalogHint }
: loaded.settings.enabled === true
? { icon: <CircleCheckIcon />, text: copy.enabledManaged }
: { icon: <CircleMinusIcon />, text: copy.disabledManaged };
return (
<div className="space-y-6">
<div className="flex items-start gap-4">
<ExtensionIconTile entry={entry} />
<div className="min-w-0 flex-1">
<h2 className="text-xl font-semibold tracking-tight">
{entry.title}
</h2>
<p className="text-muted-foreground mt-1.5 text-sm leading-6">
{entry.description}
</p>
{entry.package && (
<p className="text-muted-foreground mt-2 font-mono text-xs break-all">
{entry.package}
</p>
)}
</div>
</div>
<Alert
role={loaded?.error ? "alert" : "note"}
variant={loaded?.error ? "destructive" : "default"}
className="[&>svg]:translate-y-2"
>
{notice.icon}
<AlertDescription className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<p className="min-w-0 grow basis-64 py-1">{notice.text}</p>
{entry.guide && (
<Button
variant="outline"
size="sm"
className="text-foreground"
asChild
>
<a href={entry.guide} target="_blank" rel="noopener noreferrer">
{copy.installationGuide}
<ExternalLinkIcon />
</a>
</Button>
)}
</AlertDescription>
</Alert>
</div>
);
}
export function ExtensionGallery({ query = "" }: { query?: string }) {
const { t } = useI18n();
const { t, locale } = useI18n();
const publicQuery = useFrontendExtensions();
const params = useSearchParams();
const router = useRouter();
@ -104,6 +34,7 @@ export function ExtensionGallery({ query = "" }: { query?: string }) {
const entries = extensionDirectory(
publicQuery.data ?? [],
t.extensions.catalog,
locale,
);
const source = publicQuery;
function select(namespace?: string) {
@ -140,7 +71,7 @@ export function ExtensionGallery({ query = "" }: { query?: string }) {
if (selected) {
const entry = entries.find((item) => item.id === selected);
return (
<div className="max-w-3xl space-y-6">
<div className="space-y-6">
<div className="flex items-center justify-between gap-2">
<Button
variant="ghost"
@ -151,7 +82,6 @@ export function ExtensionGallery({ query = "" }: { query?: string }) {
<ArrowLeftIcon />
{t.extensions.all}
</Button>
{reload}
</div>
{status}
{!entry ? (
@ -159,7 +89,12 @@ export function ExtensionGallery({ query = "" }: { query?: string }) {
{t.extensions.notInstalled}
</p>
) : (
<ExtensionDetail entry={entry} copy={t.extensions} />
<ExtensionDetail
entry={entry}
discovery={
source.isPending ? "pending" : source.isError ? "error" : "ready"
}
/>
)}
</div>
);

View File

@ -3,12 +3,17 @@ import { z } from "zod";
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import { documentationSchema } from "./documentation";
const contributions = z.array(
z.object({
namespace: z.string(),
viewer_id: z.string().nullable().optional(),
module: z.string().nullable(),
backend_actions: z.array(z.string()).optional(),
model_tools: z.array(z.string()).optional(),
documentation: documentationSchema.optional().catch(undefined),
runtime_only: z.boolean().optional(),
entry: z.string().nullable(),
// Keep unknown transports so the loader can reject only that contribution.
transport: z.string().nullable().optional(),

View File

@ -0,0 +1,625 @@
{
"deerflow-extension-agent-teams": {
"schema_version": 1,
"id": "community.agent-teams",
"locales": {
"zh-CN": {
"title": "Agent 团队",
"description": "让多个 Agent 围绕同一个目标协作,共享进展,交接任务。",
"usage": [
{
"title": "组建你的团队",
"description": "选择 2–8 个已有的自定义 Agent,给团队命名,并设定共同目标。"
},
{
"title": "用 @ 分配任务",
"description": "在团队对话中输入 @ 并选择成员,再发送任务;成员会在各自的会话中处理。"
},
{
"title": "查看进展与成果",
"description": "在活动记录中查看成员回复和交接;展开任务可打开成员会话、处理提问或下载成果。"
}
],
"requirements": [
"至少准备 2 个自定义 Agent;如限制了工具分组,需要允许 extensions。"
]
},
"en-US": {
"title": "Agent teams",
"description": "Bring Agents together around a shared goal, share progress and hand off tasks.",
"usage": [
{
"title": "Build your team",
"description": "Choose 2–8 existing Custom Agents, name your team and set a shared goal."
},
{
"title": "Delegate with @mentions",
"description": "Type @ and select members in the team composer, then send a task to their own conversations."
},
{
"title": "Review progress and results",
"description": "Read replies and handoffs in the activity feed. Expand a task to open its conversation, answer questions or download results."
}
],
"requirements": [
"Prepare at least two Custom Agents; allow the extensions tool group if using a tool-group allowlist."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-agent-teams#readme"
},
"deerflow-extension-bookmarks": {
"schema_version": 1,
"id": "community.bookmarks",
"locales": {
"zh-CN": {
"title": "会话书签",
"description": "收藏有用的回答,在独立页面查找与整理自己的书签。",
"usage": [
{
"title": "收藏有用的回答",
"description": "在已有回答的会话菜单中,选择“书签 → 保存最后一条回答”。"
},
{
"title": "查找与整理",
"description": "打开“我的书签”,按文字搜索、修改名称,或回到原会话查看上下文。"
}
]
},
"en-US": {
"title": "Bookmarks",
"description": "Save useful answers and find and organize your own bookmarks on a dedicated page.",
"usage": [
{
"title": "Save a useful answer",
"description": "In a conversation with an answer, choose Bookmarks → Save last answer from its menu."
},
{
"title": "Find and organize",
"description": "Open My bookmarks to search saved text, rename entries or return to their source conversations."
}
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks#readme"
},
"deerflow-extension-example": {
"schema_version": 1,
"id": "catalog/example",
"locales": {
"zh-CN": {
"title": "扩展开发示例",
"description": "演示中间件、任务生命周期、模型观察器、服务和 HTTP 路由的扩展方式。",
"usage": [
{
"title": "安装到开发环境",
"description": "参考包内文档安装示例,配置并重启 Gateway。它用于演示扩展开发接口。"
},
{
"title": "运行任务并检查统计",
"description": "执行包含工具调用的任务,再通过示例统计 API 查看调用次数、结果类型与耗时。"
}
]
},
"en-US": {
"title": "Extension development example",
"description": "Explore middleware, task lifecycle, model observers, services and HTTP route contributions.",
"usage": [
{
"title": "Install in a development environment",
"description": "Follow the package guide to install, configure and restart Gateway. This package demonstrates extension development contracts."
},
{
"title": "Run a task and inspect statistics",
"description": "Run a task that uses tools, then inspect call counts, outcome types and durations through the example statistics API."
}
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-example#readme"
},
"deerflow-extension-jev-classify": {
"schema_version": 1,
"id": "community.jev-classify",
"locales": {
"zh-CN": {
"title": "文本分类",
"description": "按给定类别为文本列表打标签,使用部署方配置的分类服务。",
"usage": [
{
"title": "提供文本和分类标准",
"description": "在对话中提供待分类的文本列表,说明各类别的含义和边界;也可上传表格并指出文本列。"
},
{
"title": "让 Agent 执行分类",
"description": "要求使用文本分类工具,检查返回标签;如需表格,让 Agent 将标签写回输出文件。"
}
],
"requirements": [
"管理员已配置 Jev 或兼容聊天接口的分类服务;待分类文本会发送给该服务。"
]
},
"en-US": {
"title": "Text classification",
"description": "Label a list of texts with supplied categories using the deployment-configured classification service.",
"usage": [
{
"title": "Provide texts and categories",
"description": "Give the Agent a list of texts and explain the categories, or upload a table and identify its text column."
},
{
"title": "Ask the Agent to classify",
"description": "Ask it to use the text classification tool and inspect the labels. For a table, ask it to write labels into an output file."
}
],
"requirements": [
"An administrator must configure Jev or a compatible chat endpoint. Texts to classify are sent to that service."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-jev-classify#readme"
},
"deerflow-extension-jev-context": {
"schema_version": 1,
"id": "community.jev-context",
"locales": {
"zh-CN": {
"title": "上下文裁剪",
"description": "按需缩短旧的只读工具结果,保留近期消息与原生摘要。",
"usage": [
{
"title": "正常进行对话",
"description": "启用后自动检查长对话中的旧工具输出,不需要打开独立页面或输入额外命令。"
},
{
"title": "达到阈值时自动处理",
"description": "满足配置的上下文阈值和调用间隔后,裁剪已过时的只读工具结果,保留近期消息和受保护内容。"
}
],
"requirements": [
"管理员已配置 TypeSafe Jev 服务凭据及触发阈值;处理时会向该服务发送限定范围的对话内容。"
]
},
"en-US": {
"title": "Context pruning",
"description": "Shorten older read-only tool results while preserving recent messages and native summaries.",
"usage": [
{
"title": "Continue your conversation",
"description": "Once enabled, it automatically checks old tool output in long conversations. No separate page or command is needed."
},
{
"title": "Prune when the threshold is reached",
"description": "After the configured token threshold and call interval, obsolete read-only results are shortened while recent and protected content is retained."
}
],
"requirements": [
"An administrator must configure TypeSafe Jev credentials and thresholds. Processing sends bounded conversation content to that service."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-jev-context#readme"
},
"deerflow-extension-jev-screening": {
"schema_version": 1,
"id": "catalog/screening",
"locales": {
"zh-CN": {
"title": "内容风险提示",
"description": "检查获取的外部内容,为可疑工具结果添加提示,不阻断工具执行。",
"usage": [
{
"title": "按平常方式搜索与读取",
"description": "启用后,在 Agent 获取网页、搜索或 MCP 工具结果时自动检查部分内容。"
},
{
"title": "结合提示判断内容",
"description": "检测到可疑指令时,会在工具结果中增加风险提示。它提供提醒,不阻断工具,也不保证识别所有风险。"
}
],
"requirements": [
"管理员已配置 TypeSafe Jev 服务;待检查的内容片段会发送给该服务。"
]
},
"en-US": {
"title": "Content risk warnings",
"description": "Screen fetched content and add advisory warnings to suspicious tool results without blocking tools.",
"usage": [
{
"title": "Search and read as usual",
"description": "Once enabled, the middleware checks bounded excerpts of fetched pages, search results and MCP tool output."
},
{
"title": "Review advisory warnings",
"description": "Suspicious instructions add a warning to the tool result. Screening is advisory; it does not block tools or guarantee detection."
}
],
"requirements": [
"An administrator must configure TypeSafe Jev. Content excerpts are sent to that service for screening."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-jev-screening#readme"
},
"deerflow-extension-knowledge": {
"schema_version": 1,
"id": "catalog/knowledge",
"locales": {
"zh-CN": {
"title": "知识范围选择示例",
"description": "用示例文档演示知识库和文件范围选择、检索及来源引用。",
"usage": [
{
"title": "选择示例知识范围",
"description": "在对话的知识范围选择器中选择 Synthetic guides,可进一步选择 Synthetic knowledge note。"
},
{
"title": "提问并查看引用",
"description": "让 Agent 搜索 Cedar,检查回答中的来源引用。此插件使用公开的虚构演示文档。"
}
],
"requirements": [
"管理员已启用知识检索工具和范围选择,并将工具连接到此示例 Provider。"
]
},
"en-US": {
"title": "Knowledge scope example",
"description": "Search sample documents within selected knowledge bases or files and inspect source citations.",
"usage": [
{
"title": "Select the example scope",
"description": "Choose Synthetic guides in the chat scope selector, optionally narrowing it to Synthetic knowledge note."
},
{
"title": "Ask and inspect citations",
"description": "Ask the Agent to search for Cedar and inspect its source citations. This example uses a public synthetic document."
}
],
"requirements": [
"The administrator must enable knowledge retrieval and scope selection and connect the tool to this example provider."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-knowledge#readme"
},
"deerflow-extension-knowledge-center": {
"schema_version": 1,
"id": "knowledge-center",
"locales": {
"zh-CN": {
"title": "知识中心",
"description": "在独立页面浏览知识空间、导入资料,并查看文档处理状态。",
"usage": [
{
"title": "选择空间与知识库",
"description": "打开知识中心,选择你有权限访问的空间及其中的知识库。"
},
{
"title": "导入资料",
"description": "按页面提供的操作导入文件、网址或文本。可用按钮取决于你的权限和服务支持。"
},
{
"title": "查看处理状态",
"description": "检查返回的文档记录或按文档标识查询状态,必要时手动刷新。"
}
],
"requirements": [
"已安装并启用知识空间与连接、知识入库与处理扩展,并为你分配可访问的空间和资源。"
]
},
"en-US": {
"title": "Knowledge center",
"description": "Browse knowledge spaces, import content and inspect document processing on a dedicated page.",
"usage": [
{
"title": "Choose a space and knowledge base",
"description": "Open Knowledge center and select an accessible space and mapped knowledge base."
},
{
"title": "Import content",
"description": "Use the available controls to import a file, URL or text. Controls depend on your permissions and provider support."
},
{
"title": "Inspect processing",
"description": "Inspect the returned document record or look up a document by ID, refreshing its status when needed."
}
],
"requirements": [
"Enable the knowledge platform and management extensions and grant you access to spaces and resources."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-knowledge-center#readme"
},
"deerflow-extension-knowledge-management": {
"schema_version": 1,
"id": "knowledge-management",
"locales": {
"zh-CN": {
"title": "知识入库与处理",
"description": "导入文件、网址和文本,并查询、管理文档处理任务。",
"usage": [
{
"title": "连接已有管理入口",
"description": "由管理员通过知识中心或管理 API,选择已授权的知识库与操作。此扩展本身提供后台接口。"
},
{
"title": "导入并查询处理结果",
"description": "提交文件、网址或文本,保留返回的文档标识,再查询处理状态;可用操作由上游能力和权限共同决定。"
}
],
"requirements": [
"管理员已配置 WeKnora 管理连接及明确的用户、知识库和操作授权。"
]
},
"en-US": {
"title": "Knowledge ingestion and processing",
"description": "Import files, URLs and text, then inspect and manage document processing.",
"usage": [
{
"title": "Use an existing management entry",
"description": "Use Knowledge center or the management API to select an authorized knowledge base and operation. This extension provides backend endpoints."
},
{
"title": "Ingest and inspect processing",
"description": "Submit a file, URL or text, retain the returned document ID and query its status. Available operations depend on upstream capabilities and permissions."
}
],
"requirements": [
"The administrator must configure a WeKnora management connection and explicit user, knowledge-base and operation grants."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-knowledge-management#readme"
},
"deerflow-extension-knowledge-platform": {
"schema_version": 1,
"id": "knowledge-platform",
"locales": {
"zh-CN": {
"title": "知识空间与连接",
"description": "组织知识空间、连接与成员权限,让团队按授权范围使用资料。",
"usage": [
{
"title": "由管理员配置空间与资源",
"description": "通过平台管理接口配置连接、空间成员和可访问资源,明确操作权限。"
},
{
"title": "在授权范围内使用",
"description": "通过知识中心或平台接口选择可访问的空间和知识库,再进行导入与状态查询。"
}
],
"requirements": [
"已安装知识入库与处理扩展,并配置连接、持久化存储和空间策略。"
]
},
"en-US": {
"title": "Knowledge spaces and connections",
"description": "Organize knowledge spaces, connections and membership for authorized access.",
"usage": [
{
"title": "Configure spaces and resources",
"description": "An administrator configures connections, space membership, accessible resources and permitted operations through the platform API."
},
{
"title": "Work within an authorized space",
"description": "Use Knowledge center or platform APIs to select an accessible space and knowledge base, then ingest content or inspect processing."
}
],
"requirements": [
"Install the knowledge management extension and configure connections, persistent storage and space policies."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-knowledge-platform#readme"
},
"deerflow-extension-lightrag": {
"schema_version": 1,
"id": "catalog/lightrag",
"locales": {
"zh-CN": {
"title": "LightRAG",
"description": "从 LightRAG 检索文档与实体关系,并使用统一的来源引用。",
"usage": [
{
"title": "在对话中提出检索问题",
"description": "让 Agent 查询已连接知识库中的文档、实体或实体间关系。"
},
{
"title": "查看来源依据",
"description": "检查结果的文档引用;实体关系只展示服务返回的边,有文档片段支持时才提供对应引用。"
}
],
"requirements": [
"管理员已配置 LightRAG 连接、凭据、用户绑定和相关检索工具。"
]
},
"en-US": {
"title": "LightRAG",
"description": "Retrieve documents and entity relations from LightRAG with shared source citations.",
"usage": [
{
"title": "Ask a retrieval question",
"description": "Ask the Agent to search the connected knowledge base for documents, entities or relationships."
},
{
"title": "Inspect supporting sources",
"description": "Check document citations. Relations reflect returned edges and receive document citations only when supporting excerpts are available."
}
],
"requirements": [
"The administrator must configure the LightRAG connection, credentials, user bindings and retrieval tools."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-lightrag#readme"
},
"deerflow-extension-report-sections": {
"schema_version": 1,
"id": "community.report-sections",
"locales": {
"zh-CN": {
"title": "分段长文写作",
"description": "逐段撰写长报告,并将各章节汇总为可下载的 Markdown 文档。",
"usage": [
{
"title": "在对话里提出写作任务",
"description": "告诉 Agent 报告主题、目标读者和章节要求,并要求使用分段长文写作工具。"
},
{
"title": "先定大纲,再逐章完成",
"description": "让 Agent 先保存大纲,再逐章撰写和保存。需要修改时,在同一对话中指出要修改的章节。"
},
{
"title": "汇总并下载",
"description": "全部章节完成后,让 Agent 合并报告;在对话的文件成果中下载 Markdown 文档。"
}
]
},
"en-US": {
"title": "Sectioned long-form writing",
"description": "Write long reports section by section and assemble them into a downloadable Markdown document.",
"usage": [
{
"title": "Describe the report in chat",
"description": "Tell the Agent the topic, audience and chapter requirements, and ask it to use the sectioned writing tool."
},
{
"title": "Outline, then write each chapter",
"description": "Ask the Agent to save an outline, then write and save chapters one at a time. Request chapter revisions in the same conversation."
},
{
"title": "Assemble and download",
"description": "Ask the Agent to assemble the finished chapters, then download the Markdown document from the conversation artifacts."
}
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-report-sections#readme"
},
"deerflow-extension-retrieval": {
"schema_version": 1,
"id": "example.retrieval",
"locales": {
"zh-CN": {
"title": "本地检索示例",
"description": "使用固定示例文档演示只读检索与结构化工具结果,不连接外部知识库。",
"usage": [
{
"title": "在对话里试一次检索",
"description": "让 Agent 使用本地检索示例搜索 release,查看匹配到的固定演示文档。"
},
{
"title": "检查返回结果",
"description": "观察文字摘要与结构化工具成果;示例数据是虚构的,不代表真实知识库内容。"
}
]
},
"en-US": {
"title": "Local retrieval example",
"description": "Demonstrate read-only retrieval and structured tool results using fixed sample documents, without an external knowledge service.",
"usage": [
{
"title": "Try a search in chat",
"description": "Ask the Agent to use the local retrieval example to search for release and inspect the matching demonstration document."
},
{
"title": "Inspect the result",
"description": "Review its text summary and structured tool artifact. The fixed corpus is synthetic, not a real knowledge base."
}
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-retrieval#readme"
},
"deerflow-extension-weknora": {
"schema_version": 1,
"id": "catalog/weknora",
"locales": {
"zh-CN": {
"title": "WeKnora 知识检索",
"description": "检索 WeKnora 知识库和所选文档,并展示来源引用。",
"usage": [
{
"title": "选择检索范围",
"description": "在对话中选择已授权的 WeKnora 知识库,按需缩小到具体文档。"
},
{
"title": "提问并核对来源",
"description": "提出与已入库资料相关的问题,查看返回片段和来源引用。"
}
],
"requirements": [
"已有完成索引的 WeKnora 知识库;管理员已配置连接、凭据、用户授权和检索工具。"
]
},
"en-US": {
"title": "WeKnora knowledge retrieval",
"description": "Search WeKnora knowledge bases and selected documents with source citations.",
"usage": [
{
"title": "Select a search scope",
"description": "Choose an authorized WeKnora knowledge base in chat and narrow it to specific documents when needed."
},
{
"title": "Ask and verify sources",
"description": "Ask a question about indexed content and inspect the retrieved excerpts and source citations."
}
],
"requirements": [
"An indexed WeKnora knowledge base is required, with an administrator-configured connection, credentials, user grants and retrieval tool."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-weknora#readme"
},
"deerflow-extension-workflows": {
"schema_version": 1,
"id": "community.workflows",
"locales": {
"zh-CN": {
"title": "脚本工作流",
"description": "运行可检查的 Agent 脚本工作流,跟踪步骤进度,并按保留版本确认成果。",
"usage": [
{
"title": "打开脚本工作流",
"description": "进入工作流页面,准备一个受支持的 JavaScript 工作流及其输入参数。"
},
{
"title": "提交并查看执行过程",
"description": "启动后查看步骤进度、Agent 子会话和执行结果;需要人工确认的步骤会等待你的处理。"
},
{
"title": "按保留版本确认成果",
"description": "检查实际保留的输出版本,再确认或继续工作流。重启恢复取决于工作流和部署是否支持。"
}
],
"requirements": [
"管理员已配置可调用的 Agent 和持久化存储;Gateway 运行在 Linux 或 macOS。"
]
},
"en-US": {
"title": "Script workflows",
"description": "Run inspectable Agent workflows, track steps and review retained output versions.",
"usage": [
{
"title": "Open Script workflows",
"description": "Open the workflow page and prepare a supported JavaScript workflow and its input arguments."
},
{
"title": "Run and inspect",
"description": "Start the workflow and inspect steps, Agent conversations and results. Human checkpoints wait for your response."
},
{
"title": "Review retained output",
"description": "Inspect the retained output version before approving or continuing. Restart recovery depends on workflow and deployment compatibility."
}
],
"requirements": [
"The administrator must configure allowed Agents and persistent storage on a Linux or macOS Gateway."
]
}
},
"docs_url": "https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-workflows#readme"
}
}

View File

@ -1,11 +1,13 @@
import type { Translations } from "@/core/i18n/locales/types";
import { localizeDocumentation } from "./documentation";
import type { LoadedContribution } from "./registry";
// Discovery metadata only: catalog entries never enter the runtime module loader.
export const extensionCatalog = [
{
key: "agentTeams",
icon: "users",
package: "deerflow-extension-agent-teams",
namespace: "community.agent-teams",
},
@ -78,11 +80,14 @@ export interface ExtensionCatalogEntry {
guide?: string;
icon?: string;
loaded?: LoadedContribution;
gettingStarted?: { title: string; description: string }[];
requirements?: string[];
}
export function extensionDirectory(
loaded: LoadedContribution[],
copy: Translations["extensions"]["catalog"],
locale = "en-US",
): ExtensionCatalogEntry[] {
const entries: ExtensionCatalogEntry[] = extensionCatalog.map((item) => ({
id: item.namespace ?? `catalog/${item.key}`,
@ -96,11 +101,26 @@ export function extensionDirectory(
extensionCatalog.map((item) => item.namespace),
);
for (const entry of loaded) {
const page =
entry.documentation?.id === entry.namespace
? entry.documentation
: undefined;
const existing = entries.find((item) => item.id === entry.namespace);
if (existing) {
existing.loaded = entry;
if (page)
Object.assign(existing, localizeDocumentation(page, locale), {
guide: page.docs_url ?? existing.guide,
});
continue;
}
if (known.has(entry.namespace)) continue;
entries.push({
id: entry.namespace,
title: entry.title,
description: entry.description,
...(page ? localizeDocumentation(page, locale) : {}),
guide: page?.docs_url,
loaded: entry,
});
}

View File

@ -3,6 +3,8 @@ import type { Message } from "@langchain/langgraph-sdk";
import type { Translations } from "@/core/i18n";
import type { AgentThread } from "@/core/threads/types";
import type { ExtensionDocumentation } from "./documentation";
export type ExtensionSettings = Readonly<
Record<string, boolean | number | string>
>;
@ -49,6 +51,9 @@ export type FrontendContribution = {
description: string;
settings: ExtensionSettings;
backend_actions?: string[];
model_tools?: string[];
documentation?: ExtensionDocumentation;
runtime_only?: boolean;
};
export type ConversationActionContext = {
thread: AgentThread;

View File

@ -0,0 +1,56 @@
import { z } from "zod";
import bundled from "./bundled-documentation.json" with { type: "json" };
const text = z.string().trim().min(1).max(4000);
const localized = z.object({
title: text,
description: text,
usage: z
.array(z.object({ title: text, description: text }))
.min(1)
.max(12),
requirements: z.array(text).max(12).optional(),
});
export const documentationSchema = z.object({
schema_version: z.literal(1),
id: z.string(),
locales: z
.record(z.string(), localized)
.refine((value) => Object.keys(value).length > 0),
docs_url: z
.url()
.refine((value) => {
try {
const url = new URL(value);
return url.protocol === "https:" && !url.username && !url.password;
} catch {
return false;
}
})
.optional(),
});
export type ExtensionDocumentation = z.infer<typeof documentationSchema>;
export function localizeDocumentation(
doc: Pick<ExtensionDocumentation, "locales">,
locale: string,
) {
const entries = Object.entries(doc.locales);
const target = locale.toLowerCase();
const match = (tag: string) =>
entries.find(([key]) => key.toLowerCase() === tag)?.[1] ??
entries.find(
([key]) => key.toLowerCase().split("-")[0] === tag.split("-")[0],
)?.[1];
const copy = match(target) ?? match("en-us") ?? entries[0]![1];
return {
...copy,
gettingStarted: copy.usage,
requirements: copy.requirements,
};
}
export function bundledCopy(name: keyof typeof bundled, locale: string) {
return localizeDocumentation(bundled[name], locale);
}

View File

@ -5,6 +5,7 @@ import {
FileJsonIcon,
FileTextIcon,
PuzzleIcon,
UsersIcon,
type LucideIcon,
} from "lucide-react";
@ -17,6 +18,7 @@ import type { FrontendContribution, FrontendExtension } from "./contracts";
export function extensionIcon(name?: string): LucideIcon {
const icons: Record<string, LucideIcon> = {
bell: BellIcon,
users: UsersIcon,
bookmark: BookmarkIcon,
download: DownloadIcon,
"file-json": FileJsonIcon,

View File

@ -9,6 +9,8 @@ import {
VideoIcon,
} from "lucide-react";
import { bundledCopy } from "@/core/extensions/documentation";
import type { Translations } from "./types";
export const enUS: Translations = {
@ -93,82 +95,61 @@ export const enUS: Translations = {
catalogHint:
"This entry comes from the repository extension catalog. Ask your deployment administrator to confirm its installation and activation status.",
installationGuide: "Installation guide",
detail: {
guide: "Read the guide",
gettingStarted: "Get started",
requirements: "Before you start",
loaded: "Loaded",
loadedHint:
"This extension is loaded. Follow the usage instructions to get started.",
tools: "Registered tools",
pages: "Workspace pages",
management: "Usage & management",
readyHint:
"Your administrator has enabled this extension. It is ready to use.",
disabledHint:
"This extension is disabled. Ask your administrator to enable it.",
failedHint:
"The extension page could not load. Refresh extensions to try again.",
noPageHint:
"This extension has no standalone page. Check the guide for how to use it in conversations or tasks.",
guideHint:
"Read the guide to explore what this extension adds to DeerFlow and how to use it.",
technicalDetails: "Technical details",
package: "Package",
namespace: "Extension ID",
refresh: "Refresh extensions",
refreshHint:
"Refresh the page after your administrator updates an extension.",
},
catalog: {
workflows: {
title: "Script workflows",
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:
"Let full Custom Agents collaborate through @mentions, shared records and task handoffs.",
},
bookmarks: {
title: "Bookmarks",
description:
"Save useful answers and find and organize your own bookmarks on a dedicated page.",
},
context: {
title: "Context pruning",
description:
"Shorten older read-only tool results while preserving recent messages and native summaries.",
},
classify: {
title: "Text classification",
description:
"Label a list of texts with supplied categories using the deployment-configured classification service.",
},
screening: {
title: "Content risk warnings",
description:
"Screen fetched content and add advisory warnings to suspicious tool results without blocking tools.",
},
example: {
title: "Extension development example",
description:
"Explore middleware, task lifecycle, model observers, services and HTTP route contributions.",
},
retrieval: {
title: "Local retrieval example",
description:
"Demonstrate read-only retrieval and structured tool results using fixed sample documents, without an external knowledge service.",
},
lightrag: {
title: "LightRAG",
description:
"Retrieve documents and entity relations from LightRAG with shared source citations.",
},
knowledgePlatform: {
title: "Knowledge spaces and connections",
description:
"Manage hierarchical spaces, resource sharing and inherited policies with explicit administrator grants. Requires installation and configuration; no management page or global retrieval ACL is included.",
},
knowledgeCenter: {
title: "Knowledge center",
description:
"Browse authorized spaces, import files, URLs and text, and inspect document processing. Requires the separately configured knowledge platform and management extensions.",
},
knowledgeManagement: {
title: "Knowledge ingestion and processing",
description:
"Ingest files, URLs and text through WeKnora and manage processing with explicit administrator grants. Requires installation and configuration; no management page is included.",
},
weknora: {
title: "WeKnora knowledge retrieval",
description:
"Search WeKnora knowledge bases and selected documents with source citations. Requires administrator installation and access configuration.",
},
knowledge: {
title: "Knowledge scope example",
description:
"Search sample documents within selected knowledge bases or files and inspect source citations.",
},
agentTeams: bundledCopy("deerflow-extension-agent-teams", "en-US"),
bookmarks: bundledCopy("deerflow-extension-bookmarks", "en-US"),
context: bundledCopy("deerflow-extension-jev-context", "en-US"),
classify: bundledCopy("deerflow-extension-jev-classify", "en-US"),
screening: bundledCopy("deerflow-extension-jev-screening", "en-US"),
workflows: bundledCopy("deerflow-extension-workflows", "en-US"),
reportSections: bundledCopy(
"deerflow-extension-report-sections",
"en-US",
),
retrieval: bundledCopy("deerflow-extension-retrieval", "en-US"),
knowledge: bundledCopy("deerflow-extension-knowledge", "en-US"),
knowledgeManagement: bundledCopy(
"deerflow-extension-knowledge-management",
"en-US",
),
knowledgePlatform: bundledCopy(
"deerflow-extension-knowledge-platform",
"en-US",
),
knowledgeCenter: bundledCopy(
"deerflow-extension-knowledge-center",
"en-US",
),
lightrag: bundledCopy("deerflow-extension-lightrag", "en-US"),
weknora: bundledCopy("deerflow-extension-weknora", "en-US"),
example: bundledCopy("deerflow-extension-example", "en-US"),
},
pageUnavailable: "Extension page unavailable",
pageUnavailableHint:

View File

@ -77,6 +77,26 @@ export interface Translations {
catalogEntry: string;
catalogHint: string;
installationGuide: string;
detail: {
guide: string;
gettingStarted: string;
requirements: string;
loaded: string;
loadedHint: string;
tools: string;
pages: string;
management: string;
readyHint: string;
disabledHint: string;
failedHint: string;
noPageHint: string;
guideHint: string;
technicalDetails: string;
package: string;
namespace: string;
refresh: string;
refreshHint: string;
};
catalog: Record<
| "workflows"
| "reportSections"
@ -93,7 +113,12 @@ export interface Translations {
| "weknora"
| "lightrag"
| "example",
{ title: string; description: string }
{
title: string;
description: string;
gettingStarted?: { title: string; description: string }[];
requirements?: string[];
}
>;
pageUnavailable: string;
pageUnavailableHint: string;

View File

@ -9,6 +9,8 @@ import {
VideoIcon,
} from "lucide-react";
import { bundledCopy } from "@/core/extensions/documentation";
import type { Translations } from "./types";
export const zhCN: Translations = {
@ -92,75 +94,55 @@ export const zhCN: Translations = {
catalogHint:
"此条目来自仓库扩展目录;实际安装和启用状态请由部署管理员确认。",
installationGuide: "查看安装说明",
detail: {
guide: "阅读使用说明",
gettingStarted: "开始使用",
requirements: "使用前准备",
loaded: "已加载",
loadedHint: "此扩展已加载,使用方式见本页说明。",
tools: "已注册工具",
pages: "工作区入口",
management: "使用与管理",
readyHint: "此扩展已由管理员启用,可以直接使用。",
disabledHint: "此扩展已停用。请联系管理员启用后再使用。",
failedHint: "扩展页面未能加载,请刷新扩展后重试。",
noPageHint:
"此扩展没有独立工作页面。请查看使用说明,了解它在对话或任务中的使用方式。",
guideHint: "通过使用说明,了解此扩展提供的功能和使用方式。",
technicalDetails: "技术信息",
package: "安装包",
namespace: "扩展标识",
refresh: "刷新扩展",
refreshHint: "管理员更新扩展后,刷新页面以获取最新状态。",
},
catalog: {
workflows: {
title: "脚本工作流",
description:
"保存版本化 Agent 工作流,在同一 Gateway 进程内恢复,并确认确切的保留输出版本。不支持重启回放或硬金额预算。",
},
reportSections: {
title: "分段长文写作",
description: "逐段撰写长报告,并将各章节汇总为可下载的 Markdown 文档。",
},
agentTeams: {
title: "Agent 团队",
description: "让完整 Custom Agent 通过 @成员、共享记录与任务交接协作。",
},
bookmarks: {
title: "会话书签",
description: "收藏有用的回答,在独立页面查找与整理自己的书签。",
},
context: {
title: "上下文裁剪",
description: "按需缩短旧的只读工具结果,保留近期消息与原生摘要。",
},
classify: {
title: "文本分类",
description: "按给定类别为文本列表打标签,使用部署方配置的分类服务。",
},
screening: {
title: "内容风险提示",
description:
"检查获取的外部内容,为可疑工具结果添加提示,不阻断工具执行。",
},
example: {
title: "扩展开发示例",
description:
"演示中间件、任务生命周期、模型观察器、服务和 HTTP 路由的扩展方式。",
},
retrieval: {
title: "本地检索示例",
description:
"使用固定示例文档演示只读检索与结构化工具结果,不连接外部知识库。",
},
lightrag: {
title: "LightRAG",
description: "从 LightRAG 检索文档与实体关系,并使用统一的来源引用。",
},
knowledgePlatform: {
title: "知识空间与连接",
description:
"在管理员明确授权后管理多层级空间、资源共享与继承策略。需要安装和配置,不包含管理页面,也不改变全局检索权限。",
},
knowledgeCenter: {
title: "知识中心",
description:
"浏览授权空间,导入文件、网址和文本,并查询文档处理状态。需要单独配置知识平台与知识管理扩展。",
},
knowledgeManagement: {
title: "知识入库与处理",
description:
"通过 WeKnora 导入文件、网址和文本,并在管理员明确授权后管理处理任务。需要安装和配置,不包含管理页面。",
},
weknora: {
title: "WeKnora 知识检索",
description:
"检索 WeKnora 知识库和所选文档,并展示来源引用。需要管理员安装并配置访问权限。",
},
knowledge: {
title: "知识范围选择示例",
description: "用示例文档演示知识库和文件范围选择、检索及来源引用。",
},
agentTeams: bundledCopy("deerflow-extension-agent-teams", "zh-CN"),
bookmarks: bundledCopy("deerflow-extension-bookmarks", "zh-CN"),
context: bundledCopy("deerflow-extension-jev-context", "zh-CN"),
classify: bundledCopy("deerflow-extension-jev-classify", "zh-CN"),
screening: bundledCopy("deerflow-extension-jev-screening", "zh-CN"),
workflows: bundledCopy("deerflow-extension-workflows", "zh-CN"),
reportSections: bundledCopy(
"deerflow-extension-report-sections",
"zh-CN",
),
retrieval: bundledCopy("deerflow-extension-retrieval", "zh-CN"),
knowledge: bundledCopy("deerflow-extension-knowledge", "zh-CN"),
knowledgeManagement: bundledCopy(
"deerflow-extension-knowledge-management",
"zh-CN",
),
knowledgePlatform: bundledCopy(
"deerflow-extension-knowledge-platform",
"zh-CN",
),
knowledgeCenter: bundledCopy(
"deerflow-extension-knowledge-center",
"zh-CN",
),
lightrag: bundledCopy("deerflow-extension-lightrag", "zh-CN"),
weknora: bundledCopy("deerflow-extension-weknora", "zh-CN"),
example: bundledCopy("deerflow-extension-example", "zh-CN"),
},
pageUnavailable: "扩展页面不可用",
pageUnavailableHint: "此页面未注册,或插件已停用、未能加载。",

View File

@ -230,10 +230,14 @@ for (const source of ["default", "custom-toolbar", "custom-sidebar"]) {
await expect(
page.getByRole("searchbox", { name: "Search bookmarks" }),
).toHaveCount(0);
await expect(page.getByText("Enabled", { exact: true })).toBeVisible();
await expect(
page.getByText("Enabled · Managed by your administrator", {
exact: true,
}),
page.getByText(
"Your administrator has enabled this extension. It is ready to use.",
{
exact: true,
},
),
).toBeVisible();
await libraryLink.click();
await expect(page).toHaveURL(new RegExp(`${libraryURL}$`));
@ -475,8 +479,8 @@ for (const locale of ["en-US", "zh-CN"]) {
await expect(management).toHaveCount(1);
await expect(management).toContainText(
zh
? "需要安装和配置,不包含管理页面。"
: "Requires installation and configuration; no management page is included.",
? "导入文件、网址和文本,并查询、管理文档处理任务。"
: "Import files, URLs and text, then inspect and manage document processing.",
);
const lightrag = page.getByRole("article").filter({ hasText: "LightRAG" });
await expect(lightrag).toHaveCount(1);
@ -492,8 +496,8 @@ for (const locale of ["en-US", "zh-CN"]) {
await expect(weknora).toHaveCount(1);
await expect(weknora).toContainText(
zh
? "需要管理员安装并配置访问权限。"
: "Requires administrator installation and access configuration.",
? "检索 WeKnora 知识库和所选文档,并展示来源引用。"
: "Search WeKnora knowledge bases and selected documents with source citations.",
);
await weknora
.getByRole("button", {
@ -503,9 +507,17 @@ for (const locale of ["en-US", "zh-CN"]) {
.click();
await expect(
page.getByRole("link", {
name: zh ? "查看安装说明" : "Installation guide",
name: zh ? "阅读使用说明" : "Read the guide",
}),
).toHaveAttribute("href", /deerflow-extension-weknora#readme$/);
await expect(
page.getByRole("heading", {
name: zh ? "使用前准备" : "Before you start",
}),
).toBeVisible();
await expect(page.getByRole("article")).toContainText(
zh ? "管理员已配置连接" : "administrator-configured connection",
);
await page
.getByRole("button", {
name: zh ? "全部扩展" : "All extensions",
@ -533,7 +545,7 @@ for (const locale of ["en-US", "zh-CN"]) {
).toBeVisible();
await expect(
page.getByRole("link", {
name: zh ? "查看安装说明" : "Installation guide",
name: zh ? "阅读使用说明" : "Read the guide",
}),
).toHaveAttribute("href", /deerflow-extension-knowledge#readme$/);
await page
@ -547,9 +559,7 @@ for (const locale of ["en-US", "zh-CN"]) {
});
await expect(workflow).toHaveCount(1);
await expect(workflow).toContainText(
zh
? "不支持重启回放或硬金额预算"
: "No restart replay or hard monetary budget",
zh ? "按保留版本确认成果" : "review retained output versions",
);
await workflow
.getByRole("button", {
@ -559,7 +569,7 @@ for (const locale of ["en-US", "zh-CN"]) {
.click();
await expect(
page.getByRole("link", {
name: zh ? "查看安装说明" : "Installation guide",
name: zh ? "阅读使用说明" : "Read the guide",
}),
).toHaveAttribute("href", /deerflow-extension-workflows#readme$/);
await page
@ -594,6 +604,12 @@ for (const locale of ["en-US", "zh-CN"]) {
{ exact: true },
),
).toBeVisible();
await expect(
page.getByText("deerflow-extension-agent-teams", { exact: true }),
).not.toBeVisible();
await page
.getByText(zh ? "技术信息" : "Technical details", { exact: true })
.click();
await expect(
page.getByText("deerflow-extension-agent-teams", { exact: true }),
).toBeVisible();
@ -605,7 +621,7 @@ for (const locale of ["en-US", "zh-CN"]) {
).toHaveCount(0);
await expect(
page.getByRole("link", {
name: zh ? "查看安装说明" : "Installation guide",
name: zh ? "阅读使用说明" : "Read the guide",
}),
).toHaveAttribute("href", /deerflow-extension-agent-teams#readme$/);
await page
@ -634,7 +650,7 @@ for (const locale of ["en-US", "zh-CN"]) {
).toBeVisible();
await expect(
page.getByRole("link", {
name: zh ? "查看安装说明" : "Installation guide",
name: zh ? "阅读使用说明" : "Read the guide",
}),
).toHaveAttribute("href", /deerflow-extension-retrieval#readme$/);
await page
@ -657,7 +673,7 @@ for (const locale of ["en-US", "zh-CN"]) {
.click();
await expect(
page.getByRole("link", {
name: zh ? "查看安装说明" : "Installation guide",
name: zh ? "阅读使用说明" : "Read the guide",
}),
).toHaveAttribute("href", /deerflow-extension-jev-screening#readme$/);
await page
@ -702,17 +718,17 @@ test("extension detail fits a narrow viewport with its tab in view", async ({
await expect(
page.getByRole("tab", { name: "Extensions", exact: true }),
).toBeInViewport({ ratio: 1 });
await page.getByText("Technical details", { exact: true }).click();
await expect(
page.getByRole("button", {
name: "Reload extensions (refresh page)",
exact: true,
}),
page.getByRole("button", { name: "Refresh extensions", exact: true }),
).toBeVisible();
const scroller = await capabilityCenterScroller(page);
expect(
await scroller.evaluate((node) => node.scrollWidth - node.clientWidth),
).toBe(0);
const note = page.getByRole("note");
const note = page
.getByRole("complementary")
.filter({ hasText: "Usage & management" });
const noteBox = await note.boundingBox();
const textBox = await note
.getByText(
@ -833,6 +849,6 @@ test("catalog remains discoverable when runtime discovery fails", async ({
page.getByRole("heading", { name: "Knowledge scope example", exact: true }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Installation guide" }),
page.getByRole("link", { name: "Read the guide", exact: true }),
).toHaveAttribute("href", /deerflow-extension-knowledge#readme$/);
});

View File

@ -0,0 +1,189 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
test("backend-only writing instructions and optional prerequisites are visible", async ({
page,
baseURL,
}) => {
await page
.context()
.addCookies([{ name: "locale", value: "zh-CN", url: baseURL! }]);
mockLangGraphAPI(page, { threads: [] });
await page.route("**/api/plugins", (route) => route.fulfill({ json: [] }));
await page.setViewportSize({ width: 1512, height: 1000 });
await page.goto(
"/workspace/capabilities?tab=extensions&extension=community.report-sections",
);
await expect(page.getByRole("heading", { name: "汇总并下载" })).toBeVisible();
await expect(page.getByRole("heading", { name: "使用前准备" })).toHaveCount(
0,
);
await expect(page.getByRole("heading", { name: "工作区入口" })).toHaveCount(
0,
);
const directory = process.env.CAPABILITY_SCREENSHOT_DIR;
if (directory) {
await mkdir(directory, { recursive: true });
await page.screenshot({
path: path.join(directory, "report-sections-desktop.png"),
fullPage: true,
});
}
await page.setViewportSize({ width: 390, height: 844 });
expect(
await page.evaluate(
() => document.documentElement.scrollWidth - innerWidth,
),
).toBe(0);
if (directory)
await page.screenshot({
path: path.join(directory, "report-sections-mobile.png"),
fullPage: true,
});
await page.goto(
"/workspace/capabilities?tab=extensions&extension=community.jev-context",
);
await expect(page.getByRole("heading", { name: "使用前准备" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "达到阈值时自动处理" }),
).toBeVisible();
await page.setViewportSize({ width: 1512, height: 1000 });
if (directory)
await page.screenshot({
path: path.join(directory, "context-pruning-desktop.png"),
fullPage: true,
});
});
const modulePath = `/api/plugins/modules/teams.v1/${"a".repeat(64)}.mjs`;
const moduleSource = `export default {
apiVersion: 1, module: "teams.v1",
surfaces: [{ id: "teams", slot: "page", title: "Agent teams",
navigation: {label: "Agent teams", labelZh: "Agent 团队"},
mount(root) {
const heading = document.createElement("h2");
heading.textContent = "Team workspace loaded";
root.append(heading);
return {dispose() { heading.remove(); }};
}
}]
};`;
for (const locale of ["zh-CN", "en-US"]) {
test(`extension detail opens its installed page and fits desktop/mobile (${locale})`, async ({
page,
baseURL,
}) => {
test.setTimeout(120_000);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page
.context()
.addCookies([{ name: "locale", value: locale, url: baseURL! }]);
mockLangGraphAPI(page, { threads: [] });
await page.route("**/api/plugins", (route) =>
route.fulfill({
json: [
{
namespace: "community.agent-teams",
module: "teams.v1",
entry: modulePath,
title: "Agent teams",
description: "Team work",
settings: { enabled: true },
},
],
}),
);
await page.route(`**${modulePath}`, (route) =>
route.fulfill({ contentType: "text/javascript", body: moduleSource }),
);
await page.setViewportSize({ width: 1512, height: 1050 });
await page.goto(
"/workspace/capabilities?tab=extensions&extension=community.agent-teams",
);
const zh = locale === "zh-CN";
const open = page.getByRole("link", {
name: zh ? "打开 Agent 团队" : "Open Agent teams",
exact: true,
});
await expect(open).toHaveAttribute(
"href",
"/workspace/extensions/community.agent-teams/teams",
);
await expect(
page.getByRole("heading", {
name: zh ? "组建你的团队" : "Build your team",
exact: true,
}),
).toBeVisible();
await expect(
page.getByText("deerflow-extension-agent-teams", { exact: true }),
).not.toBeVisible();
const directory = process.env.CAPABILITY_SCREENSHOT_DIR;
if (directory) {
await mkdir(directory, { recursive: true });
await page.screenshot({
path: path.join(directory, `extension-detail-${locale}-desktop.png`),
fullPage: true,
});
}
await open.click();
await expect(page).toHaveURL(
/\/workspace\/extensions\/community.agent-teams\/teams$/,
{ timeout: 30_000 },
);
await expect(
page.getByRole("heading", { name: "Team workspace loaded" }),
).toBeVisible();
await page.goto(
"/workspace/capabilities?tab=extensions&extension=community.agent-teams",
);
await page.setViewportSize({ width: 390, height: 844 });
await expect(open).toBeInViewport({ ratio: 1 });
await expect
.poll(() =>
page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
),
)
.toBe(0);
const article = page.getByRole("article");
expect(
await article.evaluate((node) => node.scrollWidth - node.clientWidth),
).toBe(0);
if (directory)
await page.screenshot({
path: path.join(directory, `extension-detail-${locale}-mobile.png`),
fullPage: true,
});
await page
.getByText(zh ? "技术信息" : "Technical details", { exact: true })
.click();
await expect(
page.getByText("deerflow-extension-agent-teams", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", {
name: zh ? "刷新扩展" : "Refresh extensions",
exact: true,
}),
).toBeVisible();
await page
.getByRole("button", {
name: zh ? "全部扩展" : "All extensions",
exact: true,
})
.click();
await expect(
page.getByPlaceholder(
zh ? "按名称或用途搜索扩展" : "Search extensions by name or purpose",
),
).toBeVisible();
expect(errors).toEqual([]);
});
}

View File

@ -1,10 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { cleanup, render, screen, within } from "@testing-library/react";
import {
cleanup,
fireEvent,
render,
screen,
within,
} from "@testing-library/react";
const galleryMock = rs.hoisted(() => ({
search: "",
loaded: [] as unknown[],
replace: rs.fn(),
pending: false,
error: false,
}));
rs.mock("next/navigation", () => ({
@ -15,8 +23,8 @@ rs.mock("next/navigation", () => ({
rs.mock("@/core/extensions/hooks", () => ({
useFrontendExtensions: () => ({
data: galleryMock.loaded,
isPending: false,
isError: false,
isPending: galleryMock.pending,
isError: galleryMock.error,
refetch: rs.fn(),
}),
}));
@ -43,6 +51,8 @@ function contribution(overrides: Record<string, unknown>) {
}
beforeEach(() => {
galleryMock.pending = false;
galleryMock.error = false;
galleryMock.search = "";
galleryMock.loaded = [];
galleryMock.replace.mockClear();
@ -50,6 +60,126 @@ beforeEach(() => {
afterEach(cleanup);
describe("extension detail", () => {
it("shows package-owned usage and omits unspecified requirements", () => {
galleryMock.search = "tab=extensions&extension=community.report-sections";
render(<ExtensionGallery />);
expect(
screen.getByRole("heading", { name: "Assemble and download" }),
).toBeTruthy();
expect(
screen.queryByRole("heading", { name: "Before you start" }),
).toBeNull();
expect(
screen.queryByRole("heading", { name: "Workspace pages" }),
).toBeNull();
});
it("renders a backend extension's own documentation without a frontend module", () => {
galleryMock.search = "tab=extensions&extension=company.helper";
galleryMock.loaded = [
contribution({
namespace: "company.helper",
runtime_only: true,
documentation: {
schema_version: 1,
id: "company.helper",
locales: {
"en-US": {
title: "Background helper",
description: "Keeps statistics.",
usage: [
{
title: "Just run a task",
description: "Works automatically.",
},
],
requirements: ["Enable statistics in your deployment."],
},
},
},
}),
];
render(<ExtensionGallery />);
expect(
screen.getByRole("heading", { name: "Background helper" }),
).toBeTruthy();
expect(screen.getByText("Works automatically.")).toBeTruthy();
expect(
screen.getByRole("heading", { name: "Before you start" }),
).toBeTruthy();
expect(screen.getByText("Loaded")).toBeTruthy();
expect(screen.queryByRole("link", { name: /^Open / })).toBeNull();
});
it("opens the installed page declared by the extension, not a guessed catalog URL", () => {
galleryMock.search = "tab=extensions&extension=community.agent-teams";
galleryMock.loaded = [
contribution({
namespace: "community.agent-teams",
extension: {
apiVersion: 1,
module: "teams.v1",
surfaces: [
{
id: "custom-team-page",
slot: "page",
title: "Teams",
mount: () => ({ dispose: () => undefined }),
},
],
},
}),
];
render(<ExtensionGallery />);
expect(
screen
.getByRole("link", { name: "Open Agent teams" })
.getAttribute("href"),
).toBe("/workspace/extensions/community.agent-teams/custom-team-page");
expect(
within(screen.getAllByRole("list")[0]!).getAllByRole("listitem"),
).toHaveLength(3);
});
it.each([
"catalog",
"disabled",
"failed",
"pending",
"discovery-error",
"no-page",
])("never offers a launch link for %s", (state) => {
galleryMock.search = "tab=extensions&extension=community.agent-teams";
galleryMock.pending = state === "pending";
galleryMock.error = state === "discovery-error";
galleryMock.loaded =
state === "catalog"
? []
: [
contribution({
namespace: "community.agent-teams",
settings: { enabled: state !== "disabled" },
error: state === "failed" ? "failed" : undefined,
extension: {
apiVersion: 1,
module: "teams.v1",
surfaces:
state === "no-page"
? []
: [
{
id: "teams",
slot: "page",
title: "Teams",
mount: () => ({ dispose: () => undefined }),
},
],
},
}),
];
render(<ExtensionGallery />);
expect(screen.queryByRole("link", { name: "Open Agent teams" })).toBeNull();
});
it("shows identity, catalog status and the guide for a catalog entry", () => {
galleryMock.search = "tab=extensions&extension=community.agent-teams";
render(<ExtensionGallery />);
@ -60,26 +190,31 @@ describe("extension detail", () => {
expect(screen.getByText(copy.catalog.agentTeams.description)).toBeTruthy();
expect(screen.getByText("deerflow-extension-agent-teams")).toBeTruthy();
expect(screen.getByRole("button", { name: copy.all })).toBeTruthy();
expect(screen.getByRole("button", { name: copy.reloadAll })).toBeTruthy();
fireEvent.click(screen.getByText(copy.detail.technicalDetails));
expect(
screen.getByRole("button", { name: copy.detail.refresh }),
).toBeTruthy();
const notice = screen.getByRole("note");
const notice = screen.getByRole("complementary");
expect(within(notice).getByText(copy.catalogHint)).toBeTruthy();
expect(
within(notice)
.getByRole("link", { name: copy.installationGuide })
.getByRole("link", { name: copy.detail.guide })
.getAttribute("href"),
).toMatch(/deerflow-extension-agent-teams#readme$/);
});
it.each([
["enabled", { settings: { enabled: true } }, copy.enabledManaged],
["disabled", { settings: { enabled: false } }, copy.disabledManaged],
])("reports a loaded %s extension as a note", (_name, overrides, text) => {
["enabled", { settings: { enabled: true } }, copy.detail.readyHint],
["disabled", { settings: { enabled: false } }, copy.detail.disabledHint],
])("explains the loaded %s state", (_name, overrides, text) => {
galleryMock.search = "tab=extensions&extension=community.bookmarks";
galleryMock.loaded = [contribution(overrides)];
render(<ExtensionGallery />);
expect(within(screen.getByRole("note")).getByText(text)).toBeTruthy();
expect(
within(screen.getByRole("complementary")).getByText(text),
).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});
@ -89,7 +224,7 @@ describe("extension detail", () => {
render(<ExtensionGallery />);
expect(
within(screen.getByRole("alert")).getByText(copy.moduleUnavailable),
within(screen.getByRole("alert")).getByText(copy.detail.failedHint),
).toBeTruthy();
expect(screen.queryByRole("note")).toBeNull();
});

View File

@ -15,10 +15,66 @@ const entry = {
description: "",
};
const documentation = {
schema_version: 1,
id: "company.helper",
locales: {
"en-US": {
title: "Helper",
description: "Package instructions",
usage: [{ title: "Start", description: "Open the helper" }],
},
},
};
afterEach(() => {
rs.restoreAllMocks();
});
for (const docs_url of [
"https://example.org:bad/docs",
"https://example.org:65536/docs",
"https://[",
"http://example.org/docs",
"https://user:password@example.org/docs",
]) {
test(`invalid optional documentation URL is isolated: ${docs_url}`, async () => {
const healthy = { ...entry, settings: { enabled: true } };
const helper = {
...healthy,
namespace: documentation.id,
documentation: { ...documentation, docs_url },
};
rs.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify([healthy, helper])),
);
const discovered = await fetchFrontendExtensions();
expect(discovered).toHaveLength(2);
expect(discovered[0]).toEqual(healthy);
expect(discovered[1]?.namespace).toBe(documentation.id);
expect(discovered[1]?.documentation).toBeUndefined();
});
}
test("valid HTTPS documentation with an explicit port is retained", async () => {
const helper = {
...entry,
settings: {},
namespace: documentation.id,
documentation: {
...documentation,
docs_url: "https://example.org:8443/docs",
},
};
rs.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify([helper])),
);
expect((await fetchFrontendExtensions())[0]?.documentation).toEqual(
helper.documentation,
);
});
for (const [label, settings] of [
["empty", {}],
[

View File

@ -111,7 +111,7 @@ test("preserves custom deployment extensions and module failures", () => {
);
});
test("workflow metadata is localized, explains Phase 2 limits and merges its registration", () => {
test("workflow metadata is localized and merges its registration", () => {
const workflow: LoadedContribution = {
...contribution,
namespace: "community.workflows",
@ -122,9 +122,9 @@ test("workflow metadata is localized, explains Phase 2 limits and merges its reg
[
enUS.extensions.catalog,
"Script workflows",
"No restart replay or hard monetary budget",
"review retained output versions",
],
[zhCN.extensions.catalog, "脚本工作流", "不支持重启回放或硬金额预算"],
[zhCN.extensions.catalog, "脚本工作流", "按保留版本确认成果"],
] as const) {
const catalogOnly = extensionDirectory([], copy).find(
(entry) => entry.package === "deerflow-extension-workflows",
@ -213,3 +213,68 @@ test("knowledge platform discovery is localized and does not imply a management
expect(entry?.loaded).toBeUndefined();
}
});
test("installed documentation clears bundled requirements while retaining the README fallback", () => {
for (const requirements of [undefined, []]) {
const loaded = {
...contribution,
documentation: {
schema_version: 1 as const,
id: contribution.namespace,
locales: {
en: {
title: "Installed",
description: "Current package",
usage: [{ title: "Start", description: "Use the package" }],
...(requirements ? { requirements } : {}),
},
},
},
};
const entry = extensionDirectory([loaded], enUS.extensions.catalog).find(
(item) => item.id === loaded.namespace,
)!;
expect(entry.title).toBe("Installed");
expect(entry.requirements ?? []).toEqual([]);
expect(entry.guide).toContain("deerflow-extension-bookmarks#readme");
}
});
test("documentation matches language tags by case-insensitive exact match then language", () => {
const page = {
...contribution,
documentation: {
schema_version: 1 as const,
id: contribution.namespace,
locales: {
"en-us": {
title: "English",
description: "English",
usage: [{ title: "Start", description: "Start" }],
},
zh: {
title: "中文",
description: "中文",
usage: [{ title: "开始", description: "开始" }],
},
"zh-cn": {
title: "简体",
description: "简体",
usage: [{ title: "开始", description: "开始" }],
},
},
},
};
for (const [locale, title] of [
["en-US", "English"],
["zh-CN", "简体"],
["zh-TW", "中文"],
["fr-FR", "English"],
]) {
expect(
extensionDirectory([page], enUS.extensions.catalog, locale).find(
(item) => item.id === page.namespace,
)?.title,
).toBe(title);
}
});

View File

@ -25,7 +25,7 @@ path variables and compare storage defaults with the launcher's actual shell blo
## Dependency and Label Checks
`check_extension.py`: Gateway wheel asset validator; example CI:
`check_extension.py`: Wheel asset/documentation validator; example CI:
`extension-package-check.yml`. Tests: `backend/tests/test_extension_check_script.py`.
`check.py`: UTF-8 replacement decoding; pnpm uses

View File

@ -9,6 +9,7 @@ from __future__ import annotations
import argparse
import ast
import configparser
import json
import subprocess
import sys
import tempfile
@ -23,6 +24,11 @@ from packaging.utils import canonicalize_name
from deerflow.extensions.browser_assets import load_browser_assets
from deerflow.extensions.manager import _validate_entry_point
from deerflow.extensions.documentation import (
FILENAME,
MAX_BYTES,
validate_documentation,
)
def check_wheel(source: Path, wheel: Path, *, manifests: Sequence[str] = ()) -> None:
@ -90,6 +96,22 @@ def check_wheel(source: Path, wheel: Path, *, manifests: Sequence[str] = ()) ->
if name.endswith(".py"):
ast.parse(archive.read(name), filename=name)
module_file = (
f"{module_path}/__init__.py"
if f"{module_path}/__init__.py" in names
else f"{module_path}.py"
)
documentation_path = str(PurePosixPath(module_file).parent / FILENAME)
for import_root in (source, source / "src"):
if (
import_root / documentation_path
).is_file() and documentation_path not in names:
raise ValueError("Extension documentation is not packaged")
if documentation_path in names:
if archive.getinfo(documentation_path).file_size > MAX_BYTES:
raise ValueError("Extension documentation exceeds 64 KiB")
validate_documentation(json.loads(archive.read(documentation_path)))
# Existing examples use flat or src-layout packages and ui_manifest.json.
# Custom manifest names are explicit rather than guessing from plugin code.
manifest_paths = set(manifests)

View File

@ -0,0 +1,47 @@
"""Validate package documentation and regenerate the frontend's discovery copy."""
import argparse
import json
import runpy
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONTRACT = runpy.run_path(
str(ROOT / "backend/packages/harness/deerflow/extensions/documentation.py")
)
VALIDATE = CONTRACT["validate_documentation"]
MAX_BYTES = CONTRACT["MAX_BYTES"]
DESTINATION = ROOT / "frontend/src/core/extensions/bundled-documentation.json"
def render():
pages = {}
for path in sorted(
(ROOT / "examples").glob("deerflow-extension-*/*/deerflow-extension.json")
):
raw = path.read_bytes()
if len(raw) > MAX_BYTES:
raise ValueError(f"Documentation is too large: {path}")
page = VALIDATE(json.loads(raw))
languages = {tag.lower().split("-")[0] for tag in page["locales"]}
if not {"en", "zh"}.issubset(languages):
raise ValueError(f"Bundled documentation requires English and Chinese: {path}")
if any(existing["id"] == page["id"] for existing in pages.values()):
raise ValueError(f"Duplicate extension documentation id: {page['id']}")
pages[path.parent.parent.name] = page
return json.dumps(pages, ensure_ascii=False, indent=2) + "\n"
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
content = render()
if args.check:
if DESTINATION.read_text(encoding="utf-8") != content:
parser.exit(
1,
"Extension copy is stale; run python scripts/sync_extension_documentation.py\n",
)
else:
DESTINATION.write_text(content, encoding="utf-8")