mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 14:00:35 +00:00
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:
parent
72fb20d56d
commit
6fe87b65fa
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
@ -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]:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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")
|
||||
|
||||
224
backend/tests/test_extension_documentation.py
Normal file
224
backend/tests/test_extension_documentation.py
Normal 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"]()
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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(),
|
||||
|
||||
625
frontend/src/core/extensions/bundled-documentation.json
Normal file
625
frontend/src/core/extensions/bundled-documentation.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
56
frontend/src/core/extensions/documentation.ts
Normal file
56
frontend/src/core/extensions/documentation.ts
Normal 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);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "此页面未注册,或插件已停用、未能加载。",
|
||||
|
||||
@ -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$/);
|
||||
});
|
||||
|
||||
189
frontend/tests/e2e/extension-detail.spec.ts
Normal file
189
frontend/tests/e2e/extension-detail.spec.ts
Normal 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([]);
|
||||
});
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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", {}],
|
||||
[
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
47
scripts/sync_extension_documentation.py
Normal file
47
scripts/sync_extension_documentation.py
Normal 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")
|
||||
Loading…
x
Reference in New Issue
Block a user