diff --git a/README.md b/README.md index 6750e409c5..adf1ff999c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 260ea508ef..8da479ff4a 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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. diff --git a/backend/app/gateway/routers/plugins.py b/backend/app/gateway/routers/plugins.py index 61994903c3..f7ed1e5c52 100644 --- a/backend/app/gateway/routers/plugins.py +++ b/backend/app/gateway/routers/plugins.py @@ -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 diff --git a/backend/packages/harness/deerflow/extensions/AGENTS.md b/backend/packages/harness/deerflow/extensions/AGENTS.md index 79ae3a903e..3857bd910f 100644 --- a/backend/packages/harness/deerflow/extensions/AGENTS.md +++ b/backend/packages/harness/deerflow/extensions/AGENTS.md @@ -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 diff --git a/backend/packages/harness/deerflow/extensions/documentation.py b/backend/packages/harness/deerflow/extensions/documentation.py new file mode 100644 index 0000000000..46fcb71b34 --- /dev/null +++ b/backend/packages/harness/deerflow/extensions/documentation.py @@ -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)) diff --git a/backend/packages/harness/deerflow/extensions/loader.py b/backend/packages/harness/deerflow/extensions/loader.py index 9586f67ddf..1d7817884e 100644 --- a/backend/packages/harness/deerflow/extensions/loader.py +++ b/backend/packages/harness/deerflow/extensions/loader.py @@ -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]: diff --git a/backend/packages/harness/deerflow/extensions/registry.py b/backend/packages/harness/deerflow/extensions/registry.py index 8ddf7cc0d6..3749c4970d 100644 --- a/backend/packages/harness/deerflow/extensions/registry.py +++ b/backend/packages/harness/deerflow/extensions/registry.py @@ -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. diff --git a/backend/tests/test_extension_check_script.py b/backend/tests/test_extension_check_script.py index 7291b0101e..0476093192 100644 --- a/backend/tests/test_extension_check_script.py +++ b/backend/tests/test_extension_check_script.py @@ -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") diff --git a/backend/tests/test_extension_documentation.py b/backend/tests/test_extension_documentation.py new file mode 100644 index 0000000000..291b3dfa60 --- /dev/null +++ b/backend/tests/test_extension_documentation.py @@ -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"]() diff --git a/docs/full-stack-plugins.md b/docs/full-stack-plugins.md index f388577c57..57785d4247 100644 --- a/docs/full-stack-plugins.md +++ b/docs/full-stack-plugins.md @@ -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. diff --git a/examples/README.md b/examples/README.md index 22d3e45296..3dbd7ca928 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 diff --git a/examples/deerflow-extension-agent-teams/deerflow_extension_agent_teams/deerflow-extension.json b/examples/deerflow-extension-agent-teams/deerflow_extension_agent_teams/deerflow-extension.json new file mode 100644 index 0000000000..facdc21e51 --- /dev/null +++ b/examples/deerflow-extension-agent-teams/deerflow_extension_agent_teams/deerflow-extension.json @@ -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" +} diff --git a/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/deerflow-extension.json b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/deerflow-extension.json new file mode 100644 index 0000000000..97f62dd345 --- /dev/null +++ b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/deerflow-extension.json @@ -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" +} diff --git a/examples/deerflow-extension-example/deerflow_extension_example/deerflow-extension.json b/examples/deerflow-extension-example/deerflow_extension_example/deerflow-extension.json new file mode 100644 index 0000000000..1cc477e41d --- /dev/null +++ b/examples/deerflow-extension-example/deerflow_extension_example/deerflow-extension.json @@ -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" +} diff --git a/examples/deerflow-extension-jev-classify/deerflow_extension_jev_classify/deerflow-extension.json b/examples/deerflow-extension-jev-classify/deerflow_extension_jev_classify/deerflow-extension.json new file mode 100644 index 0000000000..30e5ac1236 --- /dev/null +++ b/examples/deerflow-extension-jev-classify/deerflow_extension_jev_classify/deerflow-extension.json @@ -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" +} diff --git a/examples/deerflow-extension-jev-context/deerflow_extension_jev_context/deerflow-extension.json b/examples/deerflow-extension-jev-context/deerflow_extension_jev_context/deerflow-extension.json new file mode 100644 index 0000000000..5f869f8297 --- /dev/null +++ b/examples/deerflow-extension-jev-context/deerflow_extension_jev_context/deerflow-extension.json @@ -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" +} diff --git a/examples/deerflow-extension-jev-screening/deerflow_extension_jev_screening/deerflow-extension.json b/examples/deerflow-extension-jev-screening/deerflow_extension_jev_screening/deerflow-extension.json new file mode 100644 index 0000000000..86eb1427a1 --- /dev/null +++ b/examples/deerflow-extension-jev-screening/deerflow_extension_jev_screening/deerflow-extension.json @@ -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" +} diff --git a/examples/deerflow-extension-knowledge-center/deerflow_extension_knowledge_center/deerflow-extension.json b/examples/deerflow-extension-knowledge-center/deerflow_extension_knowledge_center/deerflow-extension.json new file mode 100644 index 0000000000..d45f33a23b --- /dev/null +++ b/examples/deerflow-extension-knowledge-center/deerflow_extension_knowledge_center/deerflow-extension.json @@ -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" +} diff --git a/examples/deerflow-extension-knowledge-management/deerflow_extension_knowledge_management/deerflow-extension.json b/examples/deerflow-extension-knowledge-management/deerflow_extension_knowledge_management/deerflow-extension.json new file mode 100644 index 0000000000..0d82f5e9ab --- /dev/null +++ b/examples/deerflow-extension-knowledge-management/deerflow_extension_knowledge_management/deerflow-extension.json @@ -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" +} diff --git a/examples/deerflow-extension-knowledge-platform/deerflow_extension_knowledge_platform/deerflow-extension.json b/examples/deerflow-extension-knowledge-platform/deerflow_extension_knowledge_platform/deerflow-extension.json new file mode 100644 index 0000000000..7a6625e5e8 --- /dev/null +++ b/examples/deerflow-extension-knowledge-platform/deerflow_extension_knowledge_platform/deerflow-extension.json @@ -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" +} diff --git a/examples/deerflow-extension-knowledge/deerflow_extension_knowledge/deerflow-extension.json b/examples/deerflow-extension-knowledge/deerflow_extension_knowledge/deerflow-extension.json new file mode 100644 index 0000000000..1c7f69f312 --- /dev/null +++ b/examples/deerflow-extension-knowledge/deerflow_extension_knowledge/deerflow-extension.json @@ -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" +} diff --git a/examples/deerflow-extension-lightrag/deerflow_extension_lightrag/deerflow-extension.json b/examples/deerflow-extension-lightrag/deerflow_extension_lightrag/deerflow-extension.json new file mode 100644 index 0000000000..ccd7faca96 --- /dev/null +++ b/examples/deerflow-extension-lightrag/deerflow_extension_lightrag/deerflow-extension.json @@ -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" +} diff --git a/examples/deerflow-extension-report-sections/deerflow_extension_report_sections/deerflow-extension.json b/examples/deerflow-extension-report-sections/deerflow_extension_report_sections/deerflow-extension.json new file mode 100644 index 0000000000..a78e442b90 --- /dev/null +++ b/examples/deerflow-extension-report-sections/deerflow_extension_report_sections/deerflow-extension.json @@ -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" +} diff --git a/examples/deerflow-extension-retrieval/deerflow_extension_retrieval/deerflow-extension.json b/examples/deerflow-extension-retrieval/deerflow_extension_retrieval/deerflow-extension.json new file mode 100644 index 0000000000..2427c318c9 --- /dev/null +++ b/examples/deerflow-extension-retrieval/deerflow_extension_retrieval/deerflow-extension.json @@ -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" +} diff --git a/examples/deerflow-extension-weknora/deerflow_extension_weknora/deerflow-extension.json b/examples/deerflow-extension-weknora/deerflow_extension_weknora/deerflow-extension.json new file mode 100644 index 0000000000..5ac307dbc9 --- /dev/null +++ b/examples/deerflow-extension-weknora/deerflow_extension_weknora/deerflow-extension.json @@ -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" +} diff --git a/examples/deerflow-extension-workflows/deerflow_extension_workflows/deerflow-extension.json b/examples/deerflow-extension-workflows/deerflow_extension_workflows/deerflow-extension.json new file mode 100644 index 0000000000..31d1e3bbb6 --- /dev/null +++ b/examples/deerflow-extension-workflows/deerflow_extension_workflows/deerflow-extension.json @@ -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" +} diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index d29d582224..ef02220140 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 diff --git a/frontend/src/components/workspace/capabilities/extension-detail.tsx b/frontend/src/components/workspace/capabilities/extension-detail.tsx new file mode 100644 index 0000000000..f7a7b93d39 --- /dev/null +++ b/frontend/src/components/workspace/capabilities/extension-detail.tsx @@ -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 ( +
+
+
+
+
+ +
+
+
+

+ {entry.title} +

+ + {enabled ? ( + + ) : failed ? ( + + ) : null} + {label} + +
+

+ {entry.description} +

+
+
+ {primaryPage && ( + + )} +
+
+ +
+
+

+ {copy.detail.gettingStarted} +

+ {entry.gettingStarted?.length ? ( +
    + {entry.gettingStarted.map((step, index) => ( +
  1. + + {String(index + 1).padStart(2, "0")} + +
    +

    {step.title}

    +

    + {step.description} +

    +
    +
  2. + ))} +
+ ) : !pages.length ? ( +

+ {enabled ? copy.detail.noPageHint : copy.detail.guideHint} +

+ ) : null} + {pages.length > (entry.gettingStarted?.length ? 1 : 0) && ( +
+ {pages.map(({ surface, href }) => ( + + {pluginPageTitle(surface, locale)} + + + ))} +
+ )} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/workspace/capabilities/extension-gallery.tsx b/frontend/src/components/workspace/capabilities/extension-gallery.tsx index 48370b63c1..74591b89ce 100644 --- a/frontend/src/components/workspace/capabilities/extension-gallery.tsx +++ b/frontend/src/components/workspace/capabilities/extension-gallery.tsx @@ -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: , text: copy.moduleUnavailable } - : !loaded - ? { icon: , text: copy.catalogHint } - : loaded.settings.enabled === true - ? { icon: , text: copy.enabledManaged } - : { icon: , text: copy.disabledManaged }; - return ( -
-
- -
-

- {entry.title} -

-

- {entry.description} -

- {entry.package && ( -

- {entry.package} -

- )} -
-
- - {notice.icon} - -

{notice.text}

- {entry.guide && ( - - )} -
-
-
- ); -} - 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 ( -
+
- {reload}
{status} {!entry ? ( @@ -159,7 +89,12 @@ export function ExtensionGallery({ query = "" }: { query?: string }) { {t.extensions.notInstalled}

) : ( - + )}
); diff --git a/frontend/src/core/extensions/api.ts b/frontend/src/core/extensions/api.ts index b8ce37a1b9..ab77beab8d 100644 --- a/frontend/src/core/extensions/api.ts +++ b/frontend/src/core/extensions/api.ts @@ -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(), diff --git a/frontend/src/core/extensions/bundled-documentation.json b/frontend/src/core/extensions/bundled-documentation.json new file mode 100644 index 0000000000..7f8165242d --- /dev/null +++ b/frontend/src/core/extensions/bundled-documentation.json @@ -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" + } +} diff --git a/frontend/src/core/extensions/catalog.ts b/frontend/src/core/extensions/catalog.ts index 025d85c139..2524ed182f 100644 --- a/frontend/src/core/extensions/catalog.ts +++ b/frontend/src/core/extensions/catalog.ts @@ -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, }); } diff --git a/frontend/src/core/extensions/contracts.ts b/frontend/src/core/extensions/contracts.ts index 2059b40afc..f9f82b15cf 100644 --- a/frontend/src/core/extensions/contracts.ts +++ b/frontend/src/core/extensions/contracts.ts @@ -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 >; @@ -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; diff --git a/frontend/src/core/extensions/documentation.ts b/frontend/src/core/extensions/documentation.ts new file mode 100644 index 0000000000..717fe9b053 --- /dev/null +++ b/frontend/src/core/extensions/documentation.ts @@ -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; + +export function localizeDocumentation( + doc: Pick, + 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); +} diff --git a/frontend/src/core/extensions/registry.ts b/frontend/src/core/extensions/registry.ts index b9310a1382..192f469e1f 100644 --- a/frontend/src/core/extensions/registry.ts +++ b/frontend/src/core/extensions/registry.ts @@ -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 = { bell: BellIcon, + users: UsersIcon, bookmark: BookmarkIcon, download: DownloadIcon, "file-json": FileJsonIcon, diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 390c8df289..79aa37fb9d 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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: diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index cd8d3188af..eaf8770f05 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 8076bfd498..20530c862d 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -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: "此页面未注册,或插件已停用、未能加载。", diff --git a/frontend/tests/e2e/bookmark-plugin.spec.ts b/frontend/tests/e2e/bookmark-plugin.spec.ts index 7148630c4d..75b8bbcef8 100644 --- a/frontend/tests/e2e/bookmark-plugin.spec.ts +++ b/frontend/tests/e2e/bookmark-plugin.spec.ts @@ -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$/); }); diff --git a/frontend/tests/e2e/extension-detail.spec.ts b/frontend/tests/e2e/extension-detail.spec.ts new file mode 100644 index 0000000000..66ec7927fd --- /dev/null +++ b/frontend/tests/e2e/extension-detail.spec.ts @@ -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([]); + }); +} diff --git a/frontend/tests/unit/components/workspace/capabilities/extension-gallery.dom.test.tsx b/frontend/tests/unit/components/workspace/capabilities/extension-gallery.dom.test.tsx index ea1f67937f..ca9084b56f 100644 --- a/frontend/tests/unit/components/workspace/capabilities/extension-gallery.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/capabilities/extension-gallery.dom.test.tsx @@ -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) { } 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(); + 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(); + 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(); + 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(); + 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(); @@ -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(); - 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(); expect( - within(screen.getByRole("alert")).getByText(copy.moduleUnavailable), + within(screen.getByRole("alert")).getByText(copy.detail.failedHint), ).toBeTruthy(); expect(screen.queryByRole("note")).toBeNull(); }); diff --git a/frontend/tests/unit/core/extensions/api.test.ts b/frontend/tests/unit/core/extensions/api.test.ts index b3a4248b7a..178a2730e9 100644 --- a/frontend/tests/unit/core/extensions/api.test.ts +++ b/frontend/tests/unit/core/extensions/api.test.ts @@ -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", {}], [ diff --git a/frontend/tests/unit/core/extensions/catalog.test.ts b/frontend/tests/unit/core/extensions/catalog.test.ts index 80e132f6cb..8632a61c78 100644 --- a/frontend/tests/unit/core/extensions/catalog.test.ts +++ b/frontend/tests/unit/core/extensions/catalog.test.ts @@ -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); + } +}); diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 604ee99da5..4616ff4f6c 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -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 diff --git a/scripts/check_extension.py b/scripts/check_extension.py index 2dfb46f7d7..6065d507c3 100644 --- a/scripts/check_extension.py +++ b/scripts/check_extension.py @@ -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) diff --git a/scripts/sync_extension_documentation.py b/scripts/sync_extension_documentation.py new file mode 100644 index 0000000000..5a1a1ee4aa --- /dev/null +++ b/scripts/sync_extension_documentation.py @@ -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")