feat(knowledge): add optional ingestion and status center (#7518)

* feat(knowledge): add optional ingestion and status center

* docs(knowledge): record upstream CI budget fix and regression results

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
王一帆 2026-11-02 10:42:21 +08:00 committed by GitHub
parent 92325bd126
commit d0ec8dcc5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1879 additions and 6 deletions

View File

@ -1749,6 +1749,12 @@ adds connection policies, hierarchical spaces, members and explicit resource sha
Space-routed management retains existing administrator and Provider grants. It is
disabled by default and includes no management page or global chat retrieval ACL.
The optional [knowledge center extension](examples/deerflow-extension-knowledge-center/README.md)
adds an independently enabled workspace page for authorized spaces, file/URL/text
ingestion and document processing snapshots. It consumes the platform and management
APIs; it does not grant permissions or add entry editing, versions, graph browsing,
complete document/task directories or Trace. Retrieval works without it.
The optional [knowledge management extension](examples/deerflow-extension-knowledge-management/README.md)
adds administrator-authorized file, URL and manual ingestion, processing status,
reprocessing and cancellation through WeKnora. Parsing, indexing and optional

View File

@ -2,7 +2,14 @@
Reject external `sandbox`, `thread_data`, `viewed_images`, `goal_outcome`. Non-null goals: valid text, int counters/limits >=0, no Overwrite; keep sparse data.
Studio retains sanitized creation metadata.
[Viewer fence](../../../docs/full-stack-plugins.md).
Studio keeps sanitized creation metadata.
Capability Center `business` validates credentials, creates MCP connections.
MCP allows only its isolated launcher/credential environment keys; reject
arbitrary commands/manifest-policy bypasses. Keep admin checks, masked
atomic edits, cache reloads; exclude credentials from tool schemas.
HTTP and run metadata creation share `goal_thread_lock`. Lock in detached
`_ensure_thread_metadata`; preserve await-free attachment and cancel/drain

View File

@ -207,6 +207,14 @@ class AuthMiddleware(BaseHTTPMiddleware):
},
)
# Browser extension pages are bound to the account that loaded their
# descriptor. Cookies can change in another tab before React observes it.
# This is a stale-view fence, not an authorization grant; CLI callers may
# omit it and all existing route/resource checks still apply.
expected_viewer = request.headers.get("x-deerflow-plugin-viewer")
if get_request_route_path(request).startswith("/api/extensions/") and expected_viewer is not None and expected_viewer != str(user.id):
return JSONResponse(status_code=409, content={"detail": "Account changed; reload this extension view."})
# Stamp both request.state.user (for the contextvar pattern)
# and request.state.auth (so @require_permission's "auth is
# None" branch short-circuits instead of running the entire

View File

@ -66,6 +66,7 @@ EXPECTED_GUIDANCE_PATHS = {
"examples/deerflow-extension-weknora/AGENTS.md",
"examples/deerflow-extension-knowledge-management/AGENTS.md",
"examples/deerflow-extension-knowledge-platform/AGENTS.md",
"examples/deerflow-extension-knowledge-center/AGENTS.md",
"examples/deerflow-extension-lightrag/AGENTS.md",
}

View File

@ -0,0 +1,77 @@
"""The optional page must ship real assets without installing a second backend."""
from pathlib import Path
import pytest
from deerflow.extensions.loader import ExtensionSpec, load_extensions
@pytest.fixture
def load_center(monkeypatch):
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-knowledge-center"))
def load(config):
return load_extensions([ExtensionSpec(use="deerflow_extension_knowledge_center:install", config=config)])
return load
@pytest.mark.parametrize("enabled", [False, True])
def test_optional_center_has_packaged_page_and_no_management_backend(load_center, enabled):
loaded, diagnostics = load_center({"enabled": enabled})
assert not diagnostics
((_, plugin),) = loaded.plugins
assert plugin.namespace == "knowledge-center"
assert plugin.enabled is enabled
assert not plugin.backend and not plugin.tools and not loaded.routers
assets = plugin.frontend
assert assets.entry == "static/index.mjs"
assert set(assets.files) == {"static/index.mjs", "static/center.mjs", "static/styles.css", "static/copy.mjs"}
assert b"requestBackend" in assets.files["static/center.mjs"].content
@pytest.mark.parametrize("config", [{"enabled": "true"}, {"endpoint": "https://example.invalid"}, {"enabled": True, "token": "secret"}])
def test_page_config_cannot_introduce_credentials_or_provider_configuration(load_center, config):
loaded, diagnostics = load_center(config)
assert not loaded.plugins
assert diagnostics
@pytest.mark.asyncio
@pytest.mark.parametrize("method", ["GET", "POST"])
@pytest.mark.parametrize("root_path", ["", "/proxy"])
async def test_stale_browser_viewer_never_reaches_contributed_routes(monkeypatch, method, root_path):
from types import SimpleNamespace
import httpx
from fastapi import FastAPI
from app.gateway.auth_middleware import AuthMiddleware
async def user(request):
return SimpleNamespace(id="bob", system_role="admin")
async def permissions(*args, **kwargs):
return []
monkeypatch.setattr("app.gateway.auth_middleware.is_auth_disabled", lambda: False)
monkeypatch.setattr("app.gateway.deps.get_current_user_from_request", user)
monkeypatch.setattr("app.gateway.auth_middleware.resolve_route_permissions", permissions)
app = FastAPI()
app.add_middleware(AuthMiddleware)
calls = []
@app.api_route("/api/extensions/example/documents", methods=["GET", "POST"])
async def contributed():
calls.append("provider")
return {"ok": True}
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, root_path=root_path), base_url="http://test", cookies={"access_token": "session-b"}) as client:
path = root_path + "/api/extensions/example/documents"
stale = await client.request(method, path, headers={"X-Deerflow-Plugin-Viewer": "alice"})
assert stale.status_code == 409
assert calls == []
for header in ({"X-Deerflow-Plugin-Viewer": "bob"}, {}):
assert (await client.request(method, path, headers=header)).status_code == 200
assert calls == ["provider", "provider"]

View File

@ -1,5 +1,17 @@
# Full-stack plugin contributions
Page surfaces may use optional `context.requestBackend(path, options)` for
contributed `/api/extensions/` routes. It returns the HTTP Response and supports
method, headers, body (including File) and an optional signal. The host supplies
the configured Gateway URL, credentialed CSRF fetcher, descriptor-bound viewer
header and page abort signal. Foreign URLs, traversal and redirects are rejected;
writes are never retried. Gateway returns 409 for stale viewer headers before
extension dispatch. This is not an authorization grant: contributed routes must
retain all principal, plugin and resource checks. Older hosts omit this service;
modules must render an unavailable state. The independent
[knowledge center](../examples/deerflow-extension-knowledge-center/README.md)
demonstrates capability-aware ingestion and status views using existing APIs.
A deployment-installed Python extension can register a `PluginContribution` with
optional browser code, authenticated backend actions and model tools. This extends
the existing `install(registry, config)` workflow. MCP and Skills keep their existing

View File

@ -0,0 +1,16 @@
# Knowledge center
Keep this browser-only package independent of management/backend activation.
Consume platform routes through host `requestBackend`; never import host modules,
duplicate Provider logic or store credentials/content in browser storage.
Follow semantic tokens, system UI type, 8px controls, en/zh copy and 375px layouts.
Capability is not authorization or observed execution. Preserve real resource
identities, unknown states and explicit false options. Never invent directories,
task IDs, percentages or Trace. Missing entry/version/graph APIs stay unavailable.
Observe page/selection cancellation, keep one selector listener, order document
reads and invalidate them before writes. Use textContent for external text.
Never retry writes; failed submitted writes require explicit reconciliation.
Update ui_manifest.json for every asset and verify wheel contents. Tests belong
in backend/tests and frontend/tests/e2e/knowledge-center.spec.ts.

View File

@ -0,0 +1,56 @@
# Optional knowledge center extension
First usable UI slice of phase 4 in [#6269's five-stage plan](https://github.com/bytedance/deer-flow/issues/6269#issuecomment-6281785410), consuming the platform and management APIs merged in #7417 and #7293. This browser-only Full-Stack Plugin registers an independently enabled page. It adds no Provider, credentials, parser, document store, model tool or authorization bypass.
## Installation
From the repository root:
```sh
make extension-install SOURCE=./examples/deerflow-extension-knowledge-center
```
Install and configure the [knowledge platform](../deerflow-extension-knowledge-platform/README.md) and [knowledge management](../deerflow-extension-knowledge-management/README.md) separately. The management namespace must remain enabled; its direct routes may be disabled with `routes_enabled: false`. Add this entry alongside those entries in operator-owned `config.yaml`, then restart Gateway:
```yaml
plugins:
# Keep the separately configured management and platform entries here.
- use: deerflow_extension_knowledge_center:install
config:
enabled: true
```
The only page setting is boolean `enabled` (default `false`). Endpoints, keys, factories and identities are rejected. The package requires the host's optional `SurfaceContext.requestBackend` introduced with this example; older hosts display an update message. Open **Knowledge center / 知识中心** in the workspace sidebar, or `/workspace/extensions/knowledge-center/library`.
Set `enabled: false`, disable the entry point, or uninstall and restart Gateway to remove the page entry. Reload browser tabs to refresh discovery. This does not disable the platform/management API, delete policy data or upstream documents, or affect chat retrieval. Page hiding is not authorization. Installing the UI without the platform displays an unavailable state.
## Use and behavior
1. Choose an accessible space and mapped knowledge base. The current projection supplies native base IDs rather than human base titles; the selector preserves them and distinguishes connections. Empty membership/resource sets have explicit next steps.
2. The server intersects space policy with actual Provider capabilities. Non-admin members can browse effective resources but see no management controls when management authorization fails. Capability discovery is not a write grant: every server request still checks the real user, both plugin namespaces, space policy and unchanged Provider grants.
3. Import text, an HTTP(S) URL or a nonempty file up to 16 MiB using supported operations. Supported processing options preserve explicit `false` and validate paired chunk fields. Omitted new-document options inherit platform defaults. Files use the existing raw upload route, without base64 or plugin-action size limits.
4. Inspect returned document snapshots or look up an existing document by upstream ID. Refresh is explicit. Reprocess/cancel require inline confirmation; reprocessing sends empty options to preserve upstream settings. Document read ordering prevents an older status response from replacing a newer processing response.
Requests use the configured Gateway URL, host credentialed CSRF fetcher, page cancellation signal and descriptor-bound viewer header. Gateway rejects stale viewers before contributed routes run, including when another tab switches accounts. Redirects are rejected; writes are never automatically retried.
After **any failed submitted write**, the page warns that the Provider may already have accepted it and disables further writes until the user explicitly acknowledges upstream reconciliation. Check the upstream directory or query status first. Capability rejection before submission does not submit a write. Leaving the page can abort the browser wait but cannot cancel an already accepted upstream operation.
## Delivery boundary
- This is the first phase-4 slice, not the whole phase. The current management contract has no complete document/task listing, independent entry browsing/editing/versions or graph browsing API. The page explains unavailable features without inventing directories, graphs or editing endpoints. These require follow-up reviewed Provider contracts.
- At most 50 snapshots are held **for the selected resource during this visit**. They are not a durable task ledger. Reload, space/resource changes, unmount and account changes discard state. No user content is stored in localStorage/sessionStorage.
- Unknown stays unknown; `202` is not completion. `searchable` never implies all Wiki/graph/summary steps succeeded. No percentages, step timelines, elapsed times or Trace events are fabricated. Phase 5 still needs real events.
- This page does not edit platform policy/membership, create upstream bases, change chat scope, grant retrieval access or impersonate upstream users. Existing retrieval works independently.
## Verification
```sh
cd backend
uv run --no-sync pytest tests/test_knowledge_center_extension.py tests/test_auth_middleware.py tests/test_knowledge_platform_extension.py -q
cd ../frontend
pnpm check
pnpm exec rstest run tests/unit/core/extensions
pnpm exec playwright test tests/e2e/knowledge-center.spec.ts
```
Tests load the real extension/static registry, exercise Gateway authentication middleware over HTTP (including root paths and stale viewers), and load shipped native browser modules through the real host with mocked management HTTP. This is synthetic contract coverage, not a live WeKnora or production multi-user acceptance claim. Results are recorded in [VALIDATION.md](VALIDATION.md).

View File

@ -0,0 +1,83 @@
# Phase-4 knowledge center validation
## CI follow-up on 2026-10-26
The initial GitHub run passed frontend unit tests, all four browser shards,
both replay-contract layers, package checks, blocking-I/O, lint, and backend
shards 1/2/4. Backend shard 3 reported 9,607 passed and one guidance-budget
failure: the merged runtime chain was 99,004 bytes against the 98,304-byte limit.
Upstream reproduced and fixed that same failure in #7522. Main at
`3d5107dc0dd13a1f83b696b6682483aea64da896` was merged into this branch;
the guidance checker now reports **0 errors and 0 warnings**. The combined
guidance/extension/auth/platform/bookmark regression passes **139 tests**;
backend Ruff formatting and lint pass. Frontend extension unit tests remain
**100 passed**. This records local follow-up evidence;
the new head's remote CI result must be checked separately.
Validated on 2026-10-25 on Windows with Python 3.12.15, Node 24.16.0 and pnpm 10.26.2, based on `885062e197d53abb771e6c3cd1fb1223e45e9bbb` (including phase 3, #7417).
## Passing checks
| Check | Actual result |
| --- | --- |
| Backend extension, authentication, platform, bookmark and platform blocking-I/O tests | 125 passed |
| Complete strict blocking-I/O suite | 277 passed, 8 skipped |
| Guidance and new knowledge-center tests after registering the new guide | 23 passed |
| Frontend extension unit tests (11 files) | 100 passed |
| Frontend API unit tests | 87 passed |
| `pnpm check` | Passed: ESLint and TypeScript |
| Changed TypeScript and shipped native-module/style formatting | Passed |
| Backend `ruff format .` and `ruff check .`, plus new package | Passed |
| `next build --webpack`, then `node scripts/build-search-index.mjs` | Passed; search index scanned 137 pages |
| Knowledge-center Chromium tests against production server | 11 passed |
| Existing bookmark, business-plugin and plugin-icon browser regressions | 19 passed |
| `scripts/check_agent_guidance.py` | 0 errors, 7 soft-limit warnings |
| `git diff --check` | Passed |
| Wheel and sdist build; isolated wheel installation and entry-point registration | Passed; all four manifest assets included and compared with source |
The new browser suite covers English desktop and Chinese 375px mobile, dark theme, manual/URL/raw-file imports, explicit false processing fields, invalid inputs, status lookup, confirmed reprocess/cancel, revoked capabilities, browse-only membership, uncertain writes without replay, delayed read/write ordering, resource switching, disabled/empty/unavailable states and recovery. Screenshots in `screenshots/` show synthetic data rendered through the actual host.
Backend HTTP tests prove mismatched descriptor viewers cannot reach contributed GET/POST handlers, including a proxy root path. Same-viewer and headerless clients retain existing behavior. The initial extension tests and stale-viewer tests failed before their corresponding implementation; the delayed-status browser regression also failed with the read-order guard removed.
## Broader-suite limitations
The whole local test run is **not green**:
- Backend offline attempt: **4,224 passed, 172 skipped, 14 deselected, 10 failed**, stopped by `--maxfail=10`. One failure was the newly added guide missing from the approved guidance inventory; fixed and verified by the 23-test run above. Other failures were an atomic worker timeout, a quantized-clock benchmark precondition and seven credential-staging cases using unsupported Windows `os.utime(..., follow_symlinks=False)`.
- The worker timeout and credential-staging failures were reproduced in a separate, unchanged worktree at the exact base commit. The quantized-clock case passed alone on this branch and on the unchanged base. These results do not establish that the rest of the unexecuted backend suite passes.
- Frontend full unit run: **3,544 passed, 5 failed** from five-second timeouts in the unchanged scheduled-task schedule-input and task-form-dialog tests. Rerunning those two files gave **45 passed, 1 timeout**; that remaining task-form case passed when run alone. No scheduled-task production or test files were changed. The complete suite was not rerun to claim an all-green result.
- The first production-build attempt used dependencies from an older worktree and failed on missing `@headlessui/react`. After a local `pnpm install --frozen-lockfile`, the production Webpack build and `pnpm check` both passed. No dependency manifests or locks changed.
Native Windows SWC is blocked by this machine's application-control policy, so the production build uses the supported Webpack option. `make` is unavailable locally; the equivalent formatter, linter and test commands were run directly.
## Reproduction
From `backend/`:
```sh
uv run --no-sync pytest tests/test_knowledge_center_extension.py tests/test_auth_middleware.py tests/test_knowledge_platform_extension.py tests/test_bookmark_plugin.py tests/blocking_io/test_knowledge_platform_extension.py -q
uv run --no-sync pytest tests/blocking_io -q
uv run --no-sync pytest tests/test_agent_guidance_check.py tests/test_knowledge_center_extension.py -q
uv run --no-sync ruff format .
uv run --no-sync ruff check .
```
From `frontend/`:
```sh
pnpm check
pnpm exec rstest run tests/unit/core/extensions
pnpm exec rstest run tests/unit/core/api
# Set BETTER_AUTH_SECRET=local-dev-secret, SKIP_ENV_VALIDATION=1,
# DEER_FLOW_AUTH_DISABLED=1 in the shell before building/starting.
pnpm exec next build --webpack
node scripts/build-search-index.mjs
pnpm exec next start --hostname 127.0.0.1 --port 3016
# In another shell, set PLAYWRIGHT_BASE_URL=http://127.0.0.1:3016
# and PLAYWRIGHT_SKIP_WEB_SERVER=1. Direct Node avoids a local Windows
# pnpm argument-forwarding issue with Playwright filename filters.
node node_modules/@playwright/test/cli.js test tests/e2e/knowledge-center.spec.ts --workers=2 --reporter=line
node node_modules/@playwright/test/cli.js test tests/e2e/bookmark-plugin.spec.ts tests/e2e/business-plugins.spec.ts tests/e2e/plugin-icons.spec.ts --workers=2 --reporter=line
```
From this package directory, `uv build` builds the independently installable wheel and sdist. Tests exercise the real extension loader/static registry, authentication middleware and native browser modules; management HTTP responses in the browser suite are mocked. No live WeKnora connection, production deployment, persistent task ledger, multi-user acceptance, complete phase-4 implementation or phase-5 Trace is claimed.

View File

@ -0,0 +1,22 @@
"""Browser-only client of the independently authorized platform API."""
from pathlib import Path
from deerflow_extension_api import extension
from deerflow_extension_api.plugins import BrowserAssets, PluginContribution
@extension(api="0.2.9", name="knowledge-center")
def install(registry, config):
if set(config) - {"enabled"} or type(config.get("enabled", False)) is not bool:
raise ValueError("Knowledge center accepts only a boolean enabled setting")
if not registry.plugin(
PluginContribution(
namespace="knowledge-center",
title="知识中心 / Knowledge center",
description="浏览授权空间,导入知识并查询处理状态 / Browse authorized spaces, import knowledge and inspect processing status",
enabled=config.get("enabled", False),
frontend=BrowserAssets("knowledge-center.v1", Path(__file__).parent),
)
):
raise RuntimeError("Knowledge center requires the full-stack plugin host")

View File

@ -0,0 +1,542 @@
import { copy } from "./copy.mjs";
const API = "/api/extensions/knowledge-platform";
const enc = encodeURIComponent;
const MAX_FILE = 16 * 1024 * 1024;
// No host imports, provider credentials, persistence or automatic write retries.
export function mountCenter(root, context) {
const w = copy[context.locale.startsWith("zh") ? "zh" : "en"];
const make = (tag, text = "", attrs = {}) => {
const node = document.createElement(tag);
node.textContent = text;
for (const [key, value] of Object.entries(attrs))
node.setAttribute(key, value);
return node;
};
const listen = (node, event, handler) =>
node.addEventListener(event, handler, { signal: context.signal });
const button = (text, handler) => {
const node = make("button", text, { type: "button" });
listen(node, "click", handler);
return node;
};
const field = (label, node) => {
const wrapper = make("label", label);
wrapper.append(node);
return wrapper;
};
const select = (label, items) => {
const node = make("select", "", { "aria-label": label });
for (const [value, text] of items)
node.append(make("option", text, { value }));
return node;
};
const style = make("link", "", {
rel: "stylesheet",
crossorigin: "use-credentials",
href: new URL("./styles.css", import.meta.url).href,
});
const shell = make("div", "", { class: "center" });
const status = make("p", "", { role: "status" });
const error = make("p", "", { role: "alert", class: "error" });
const spaces = select(w.space, []);
const bases = select(w.base, []);
const content = make("div");
const reload = button(w.reload, () => void run(loadSpaces));
const selectors = make("div", "", { class: "selectors" });
selectors.append(field(w.space, spaces), field(w.base, bases), reload);
shell.append(
make("h2", w.heading),
make("p", w.intro, { class: "intro" }),
selectors,
status,
error,
content,
);
root.append(style, shell);
let disposed = false;
let generation = 0;
let controller = new AbortController();
let busy = false;
let latestResources = [];
// Selection-scoped only. No browser storage across accounts or visits.
let records = new Map();
const active = (g) =>
!disposed && !context.signal.aborted && g === generation;
const message = (cause) =>
({
401: w.denied,
403: w.denied,
404: w.unavailable,
409: w.conflict,
422: w.invalid,
})[cause.status] ?? w.failed;
async function request(path, options = {}) {
const response = await context.requestBackend(path, {
...options,
signal: controller.signal,
});
if (!response.ok)
throw Object.assign(new Error("Knowledge request failed"), {
status: response.status,
});
return response.json();
}
async function run(operation) {
error.textContent = "";
const pending = operation();
const g = generation;
try {
await pending;
} catch (cause) {
if (active(g) && cause.name !== "AbortError") {
status.textContent = "";
error.textContent = message(cause);
}
}
}
function reset() {
controller.abort();
controller = new AbortController();
generation++;
records = new Map();
error.textContent = "";
status.textContent = "";
content.replaceChildren();
}
async function loadSpaces() {
reset();
const g = generation;
spaces.disabled = bases.disabled = true;
spaces.replaceChildren();
bases.replaceChildren();
status.textContent = w.loading;
const data = await request(`${API}/spaces`);
if (!active(g)) return;
for (const item of data.items)
spaces.append(make("option", item.title, { value: item.space_id }));
spaces.disabled = data.items.length === 0;
if (!data.items.length) {
status.textContent = w.noSpaces;
return;
}
await run(loadSpace);
}
async function loadSpace() {
reset();
const g = generation;
bases.disabled = true;
bases.replaceChildren();
status.textContent = w.loading;
const data = await request(`${API}/spaces/${enc(spaces.value)}`);
if (!active(g)) return;
for (const item of data.resources) {
// The current platform projection has no human knowledge-base title.
// Keep the exact native identifier visible instead of inventing one.
bases.append(
make("option", `${item.native_base_id} · ${item.connection_id}`, {
value: item.resource_id,
}),
);
}
bases.disabled = data.resources.length === 0;
latestResources = data.resources;
if (!data.resources.length) {
status.textContent = w.noBases;
return;
}
await loadResource(data.resources);
}
async function loadResource(resources) {
const item = resources.find((r) => r.resource_id === bases.value);
if (!item) return;
reset();
const g = generation;
const prefix = `${API}/spaces/${enc(spaces.value)}/resources/${enc(item.resource_id)}`;
status.textContent = w.loading;
const details = make("details");
details.append(
make("summary", w.details),
make(
"pre",
JSON.stringify(
{
provider: item.provider,
connection: item.connection_id,
knowledge_base: item.native_base_id,
dataset: item.dataset_id,
processing: item.processing,
},
null,
2,
),
),
);
content.append(details);
const future = make("section", "", { class: "future" });
future.append(make("h3", w.future), make("p", w.futureText));
let capabilities;
try {
capabilities = await request(`${prefix}/capabilities`);
} catch (cause) {
if (!active(g)) return;
status.textContent = "";
content.append(
make("p", cause.status === 403 ? w.browseOnly : message(cause)),
future,
);
return;
}
if (!active(g)) return;
status.textContent = "";
const operations = new Set(capabilities.operations);
const supported = new Set(capabilities.processing_options);
const section = make("section");
const taskSection = make("section");
let uncertain = false;
let readEpoch = 0;
const readVersions = new Map();
const warning = make("div", "", { class: "warning" });
warning.hidden = true;
const acknowledge = button(w.reconcile, () => {
uncertain = false;
warning.hidden = true;
controls();
});
warning.append(make("p", w.uncertain), acknowledge);
const mutationControls = [];
const controls = () => {
for (const node of mutationControls) node.disabled = busy || uncertain;
spaces.disabled = bases.disabled = reload.disabled = busy;
};
const write = async (action, operation) => {
if (busy || uncertain || !active(g)) return;
readEpoch++;
busy = true;
controls();
error.textContent = "";
let submitted = false;
try {
// Refresh capabilities before every explicit write; server admission is authoritative.
const fresh = await request(`${prefix}/capabilities`);
if (!active(g)) return;
if (!fresh.operations.includes(action))
throw Object.assign(new Error("Unsupported"), { status: 422 });
submitted = true;
const result = await operation();
if (!active(g)) return;
record(result);
status.textContent = w.accepted;
} catch (cause) {
if (active(g) && cause.name !== "AbortError") {
error.textContent = message(cause);
if (submitted) {
uncertain = true;
warning.hidden = false;
}
}
} finally {
busy = false;
if (active(g)) controls();
}
};
const jsonWrite = (path, body) =>
request(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
section.append(make("h3", w.import), warning);
const kinds = ["manual", "url", "upload"].filter((kind) =>
operations.has(kind),
);
if (kinds.length) {
const form = make("form");
const kind = select(
w.import,
kinds.map((k) => [k, w[k]]),
);
const title = make("input", "", { maxlength: "500" });
const text = make("textarea", "", { rows: "7", maxlength: "1000000" });
const url = make("input", "", { type: "url" });
const file = make("input", "", { type: "file" });
const titleField = field(w.title, title),
textField = field(w.content, text),
urlField = field(w.address, url),
fileField = field(w.file, file);
form.append(
field(w.import, kind),
titleField,
textField,
urlField,
fileField,
);
const updateKind = () => {
titleField.hidden = kind.value === "upload";
textField.hidden = kind.value !== "manual";
urlField.hidden = kind.value !== "url";
fileField.hidden = kind.value !== "upload";
};
listen(kind, "change", updateKind);
updateKind();
const optionFields = new Map();
const options = make("details");
options.append(make("summary", w.options), make("p", w.defaults));
for (const [key, label] of [
["summary_enabled", w.summary],
["graph_enabled", w.graph],
]) {
if (!supported.has(key)) continue;
const node = select(label, [
["", w.inherit],
["true", w.yes],
["false", w.no],
]);
options.append(field(label, node));
optionFields.set(key, node);
}
if (supported.has("chunk_size") && supported.has("chunk_overlap")) {
for (const [key, label, min, max] of [
["chunk_size", w.chunkSize, 64, 8192],
["chunk_overlap", w.overlap, 0, 4096],
]) {
const node = make("input", "", {
type: "number",
min,
max,
step: "1",
});
options.append(field(label, node));
optionFields.set(key, node);
}
}
form.append(options);
const validation = make("p", "", { role: "alert", class: "error" });
const submit = make("button", w.submit, {
type: "submit",
class: "primary",
});
mutationControls.push(submit);
form.append(validation, submit);
section.append(form);
listen(form, "submit", (event) => {
event.preventDefault();
validation.textContent = "";
const processing = {};
for (const [key, node] of optionFields) {
if (node.value !== "")
processing[key] = key.startsWith("chunk_")
? Number(node.value)
: node.value === "true";
}
const size = processing.chunk_size,
overlap = processing.chunk_overlap;
if (
(size === undefined) !== (overlap === undefined) ||
(size !== undefined &&
(!Number.isInteger(size) ||
size < 64 ||
size > 8192 ||
!Number.isInteger(overlap) ||
overlap < 0 ||
overlap > 4096 ||
overlap >= size))
) {
validation.textContent = w.badChunks;
return;
}
const mode = kind.value;
if (mode === "manual" && (!title.value.trim() || !text.value.trim())) {
validation.textContent = w.required;
return;
}
if (mode === "url") {
try {
const parsed = new URL(url.value);
if (
!["http:", "https:"].includes(parsed.protocol) ||
parsed.username ||
parsed.password ||
parsed.hash
)
throw new Error();
} catch {
validation.textContent = w.badUrl;
return;
}
}
const selected = file.files?.[0];
if (
mode === "upload" &&
(!selected || !selected.size || selected.size > MAX_FILE)
) {
validation.textContent = w.fileSize;
return;
}
const body = {
kind: mode,
title: title.value,
processing,
...(mode === "manual" ? { content: text.value } : { url: url.value }),
};
// Serialize now, before async admission, so edits cannot alter the admitted request.
const encoded = JSON.stringify(body);
void write(mode, () =>
mode === "upload"
? request(`${prefix}/files?filename=${enc(selected.name)}`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"X-Processing-Options": JSON.stringify(processing),
},
body: selected,
})
: request(`${prefix}/documents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: encoded,
}),
);
});
} else section.append(make("p", w.noImport));
taskSection.append(make("h3", w.tasks), make("p", w.taskIntro));
const rows = make("div", "", { class: "records" });
const record = (result) => {
if (
!result ||
typeof result.document_id !== "string" ||
result.provider !== item.provider ||
result.connection_id !== item.connection_id ||
result.dataset_id !== item.dataset_id
)
throw new Error("Invalid resource response");
records.set(result.document_id, result);
if (records.size > 50) records.delete(records.keys().next().value);
renderRecords();
};
const lookup = async (id) => {
if (busy) return;
const epoch = readEpoch;
const version = (readVersions.get(id) ?? 0) + 1;
readVersions.set(id, version);
const result = await request(`${prefix}/documents/${enc(id)}`);
if (
active(g) &&
epoch === readEpoch &&
version === readVersions.get(id) &&
!busy
)
record(result);
};
if (operations.has("status")) {
const form = make("form", "", { class: "lookup" });
const id = make("input", "", { required: "", maxlength: "256" });
const submit = make("button", w.lookup, { type: "submit" });
form.append(field(w.document, id), submit);
taskSection.append(form);
listen(form, "submit", (event) => {
event.preventDefault();
if (!id.value.trim()) return;
submit.disabled = true;
void run(() => lookup(id.value.trim())).finally(() => {
if (active(g)) submit.disabled = false;
});
});
}
function renderRecords() {
rows.replaceChildren();
mutationControls.splice(
0,
mutationControls.length,
...mutationControls.filter((node) => content.contains(node)),
);
for (const result of records.values()) {
const row = make("article");
const stateKey =
result.state === "failed" ? "failedState" : result.state;
const state = [
"unknown",
"queued",
"running",
"completed",
"failedState",
"cancelled",
].includes(stateKey)
? w[stateKey]
: w.unknown;
row.append(
make("h4", result.document_id),
make(
"p",
`${w.state}: ${state} · ${result.searchable === true ? w.searchable : w.notSearchable}`,
),
);
const raw = make("details");
raw.append(
make("summary", w.providerState),
make("pre", String(result.provider_state)),
);
row.append(raw);
const actions = make("div", "", { class: "actions" });
if (operations.has("status"))
actions.append(
button(w.refresh, () => void run(() => lookup(result.document_id))),
);
for (const action of ["reprocess", "cancel"]) {
if (!operations.has(action)) continue;
const confirm = make("span", "", { class: "confirmation" });
confirm.hidden = true;
const trigger = button(w[action], () => {
confirm.hidden = false;
});
const yes = button(w.confirm, () => {
confirm.hidden = true;
void write(action, () =>
jsonWrite(
`${prefix}/documents/${enc(result.document_id)}/${action}`,
{},
),
);
});
mutationControls.push(trigger, yes);
confirm.append(
make(
"span",
action === "cancel" ? w.confirmCancel : w.confirmReprocess,
),
yes,
button(w.back, () => {
confirm.hidden = true;
}),
);
actions.append(trigger, confirm);
}
row.append(actions);
rows.append(row);
}
controls();
}
taskSection.append(rows, make("p", w.snapshots, { class: "note" }));
content.append(section, taskSection, future);
controls();
}
// One current-resource list; avoid accumulating handlers on repeated reloads.
listen(spaces, "change", () => {
if (!busy) void run(loadSpace);
});
listen(bases, "change", () => {
if (!busy) void run(() => loadResource(latestResources));
});
if (!context.requestBackend) error.textContent = w.oldHost;
else void run(loadSpaces);
return {
dispose() {
disposed = true;
controller.abort();
generation++;
records.clear();
root.replaceChildren();
},
};
}

View File

@ -0,0 +1,161 @@
export const copy = {
en: {
heading: "Your knowledge, ready to work",
intro:
"Choose a space and knowledge base to import documents or check processing. Access follows your account and the connected provider.",
space: "Space",
base: "Knowledge base",
reload: "Reload spaces",
loading: "Loading…",
noSpaces:
"No accessible spaces. Ask your administrator to configure space membership and resources.",
noBases:
"This space has no available knowledge bases. Ask your administrator to check its resource policy.",
oldHost:
"This host does not support extension HTTP requests. Update DeerFlow to use the knowledge center.",
browseOnly:
"You can browse this space. Document management requires separate administrator and provider permissions.",
denied:
"Access denied. Ask your administrator to check space, plugin and provider permissions.",
unavailable:
"Knowledge platform unavailable. Check that the platform extension is installed, enabled and configured.",
conflict:
"The account, policy or resource changed. Reload and reconcile before trying again.",
invalid:
"The provider does not support this request, or the input is invalid. Check the selected capability and fields.",
failed:
"The request could not be completed. Check the connection and try a fresh read.",
uncertain:
"The provider may have accepted this write. Check its document directory or query the document status before submitting again. This page never retries writes automatically.",
reconcile: "I checked the upstream result",
import: "Import document",
manual: "Write text",
url: "Import URL",
upload: "Upload file",
title: "Title",
content: "Document text",
address: "Document URL",
file: "File (up to 16 MiB)",
submit: "Submit document",
required: "Complete the required fields.",
badUrl: "Use an HTTP or HTTPS URL without credentials or a fragment.",
fileSize: "Select a nonempty file no larger than 16 MiB.",
options: "Processing options",
inherit: "Use inherited setting",
yes: "Enabled",
no: "Disabled",
summary: "Summary",
graph: "Graph extraction",
chunkSize: "Chunk size (64–8192)",
overlap: "Chunk overlap (0–4096)",
badChunks:
"Supply both chunk fields, with overlap smaller than size, or leave both empty.",
defaults:
"Empty options use the space defaults for new documents. Reprocessing preserves upstream settings.",
tasks: "Document processing",
taskIntro:
"Look up a document by its upstream ID. Results below belong to this visit and this knowledge base; they are not a complete document or task directory.",
document: "Document ID",
lookup: "Check status",
refresh: "Refresh status",
reprocess: "Reprocess",
cancel: "Cancel processing",
confirm: "Confirm",
back: "Keep unchanged",
confirmReprocess:
"Submit reprocessing for this document using its upstream settings?",
confirmCancel: "Request cancellation for this document?",
state: "Processing state",
searchable: "Searchable",
notSearchable: "Not searchable",
details: "Resource details",
providerState: "Provider state",
accepted:
"Submission returned. Check the document state below; acceptance does not mean processing completed.",
unknown: "Unknown",
queued: "Queued",
running: "Running",
completed: "Completed",
failedState: "Failed",
cancelled: "Cancelled",
snapshots:
"These are provider status snapshots. Searchable does not mean every summary, Wiki or graph step succeeded.",
future: "Knowledge entries and graph browsing",
futureText:
"Entry editing, version history and graph browsing are not available for this connection. You can still search knowledge through the existing chat tools.",
noImport: "No import operation is available for this resource.",
},
zh: {
heading: "让知识随时可用",
intro:
"选择空间和知识库,导入文档或查询处理状态。访问范围由当前账号和上游服务共同校验。",
space: "知识空间",
base: "知识库",
reload: "重新加载空间",
loading: "正在加载…",
noSpaces: "暂无可访问的空间。请管理员配置空间成员和知识资源。",
noBases: "此空间暂无可用知识库。请管理员检查资源策略。",
oldHost: "当前宿主不支持扩展 HTTP 请求,请更新 DeerFlow 后使用知识中心。",
browseOnly: "你可以浏览此空间。文档管理还需要单独的管理员权限与上游授权。",
denied: "无权访问。请管理员检查空间、插件和上游服务的授权。",
unavailable: "知识平台不可用,请检查平台扩展是否已安装、启用并配置。",
conflict: "账号、策略或资源已发生变化,请重新加载并核对结果后再操作。",
invalid: "上游不支持此请求,或输入无效。请检查当前能力和输入内容。",
failed: "请求未能完成,请检查连接后重新查询。",
uncertain:
"上游可能已经接受本次写入。再次提交前,请核对上游文档目录或查询文档状态。页面不会自动重试写操作。",
reconcile: "我已核对上游结果",
import: "导入文档",
manual: "手工录入",
url: "导入网址",
upload: "上传文件",
title: "标题",
content: "文档内容",
address: "文档网址",
file: "文件(最大 16 MiB)",
submit: "提交文档",
required: "请填写必填内容。",
badUrl: "请使用 HTTP 或 HTTPS 网址,不包含账号密码或片段标识。",
fileSize: "请选择非空且不超过 16 MiB 的文件。",
options: "处理选项",
inherit: "使用继承配置",
yes: "开启",
no: "关闭",
summary: "摘要",
graph: "图谱抽取",
chunkSize: "分块大小(64–8192)",
overlap: "分块重叠(0–4096)",
badChunks: "请同时填写两个分块参数,重叠量小于分块大小;或同时留空。",
defaults: "新文档的空选项使用空间默认值。重新处理保留上游配置。",
tasks: "文档处理",
taskIntro:
"使用上游文档 ID 查询状态。下方仅展示本次访问中当前知识库的查询记录,不代表完整文档或任务目录。",
document: "文档 ID",
lookup: "查询状态",
refresh: "刷新状态",
reprocess: "重新处理",
cancel: "取消处理",
confirm: "确认操作",
back: "保持不变",
confirmReprocess: "确认按上游现有配置重新处理此文档?",
confirmCancel: "确认请求取消此文档的处理?",
state: "处理状态",
searchable: "可检索",
notSearchable: "暂不可检索",
details: "资源详情",
providerState: "上游状态",
accepted: "提交已返回,请以下方文档状态为准;请求被接受不代表处理完成。",
unknown: "未知",
queued: "排队中",
running: "处理中",
completed: "已完成",
failedState: "失败",
cancelled: "已取消",
snapshots:
"这里展示上游状态快照。可检索不代表摘要、Wiki 或图谱的每一步都已成功。",
future: "知识条目与图谱浏览",
futureText:
"此连接暂不支持条目编辑、版本记录和图谱浏览。你仍可通过已有聊天工具检索知识。",
noImport: "当前资源没有可用的导入操作。",
},
};

View File

@ -0,0 +1,20 @@
import { mountCenter } from "./center.mjs";
export default {
apiVersion: 1,
module: "knowledge-center.v1",
icon: "book-open",
surfaces: [
{
id: "library",
slot: "page",
title: "Knowledge center",
navigation: {
label: "Knowledge center",
labelZh: "知识中心",
icon: "book-open",
},
mount: mountCenter,
},
],
};

View File

@ -0,0 +1,211 @@
:host {
color: var(--foreground);
font: 14px/1.6 var(--font-sans, ui-sans-serif, system-ui);
}
* {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
.center {
max-width: 960px;
margin: 0 auto;
padding: 24px 16px 64px;
}
h2 {
font-size: 24px;
line-height: 1.3;
letter-spacing: -0.012em;
margin: 0 0 12px;
font-weight: 600;
text-wrap: balance;
}
h3 {
font-size: 18px;
margin: 0 0 12px;
font-weight: 600;
}
h4 {
font-size: 14px;
margin: 0;
overflow-wrap: anywhere;
}
p {
margin: 8px 0 16px;
text-wrap: pretty;
}
.intro {
max-width: 640px;
}
.intro,
.note,
.future,
summary {
color: var(--muted-foreground);
}
.selectors {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 16px;
align-items: end;
margin: 28px 0 16px;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
min-width: 0;
font-weight: 500;
}
.selectors label {
margin: 0;
}
input,
select,
textarea,
button {
font: inherit;
color: inherit;
border: 1px solid var(--input, var(--border));
border-radius: 8px;
background: var(--background);
min-width: 0;
}
input,
select,
textarea {
padding: 8px 12px;
width: 100%;
}
textarea {
resize: vertical;
}
button {
padding: 8px 16px;
cursor: pointer;
font-weight: 500;
min-height: 40px;
transition:
background-color 150ms,
transform 150ms;
}
button:hover {
background: var(--accent);
}
button:active {
transform: scale(0.97);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.primary {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
}
.primary:hover {
background: color-mix(in oklch, var(--primary) 85%, var(--background));
}
:is(button, input, select, textarea, summary):focus-visible {
outline: 2px solid var(--ring);
outline-offset: 3px;
}
section {
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid var(--border);
}
form {
max-width: 680px;
}
details {
margin: 12px 0 20px;
}
summary {
cursor: pointer;
}
details label {
margin-top: 12px;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
font:
13px/1.6 ui-monospace,
monospace;
}
.error {
color: var(--destructive);
}
.error:empty,
[role="status"]:empty {
display: none;
}
.warning {
padding: 16px;
border: 1px solid var(--border);
background: var(--secondary);
border-radius: 10px;
margin-bottom: 20px;
}
.lookup {
display: flex;
align-items: end;
gap: 12px;
}
.lookup label {
flex: 1;
margin-bottom: 0;
}
.lookup button {
flex-shrink: 0;
}
article {
padding: 20px 0;
border-bottom: 1px solid var(--border);
}
.actions,
.confirmation {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.confirmation {
flex-basis: 100%;
padding: 12px;
background: var(--secondary);
border-radius: 8px;
}
.confirmation span {
flex-basis: 100%;
}
@media (max-width: 640px) {
.center {
padding: 16px 4px 40px;
}
.selectors {
grid-template-columns: 1fr;
gap: 12px;
}
.selectors button {
justify-self: start;
}
.lookup {
flex-direction: column;
align-items: stretch;
}
input,
select,
button {
min-height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
button {
transition: none;
}
}

View File

@ -0,0 +1,10 @@
{
"schema_version": 1,
"entry": "static/index.mjs",
"files": [
"static/index.mjs",
"static/center.mjs",
"static/copy.mjs",
"static/styles.css"
]
}

View File

@ -0,0 +1,17 @@
[project]
name = "deerflow-extension-knowledge-center"
version = "0.1.0"
description = "Optional capability-aware knowledge center pages for DeerFlow"
requires-python = ">=3.12"
license = "MIT"
dependencies = ["deerflow-extension-api>=0.2.9,<0.3"]
[project.entry-points."deerflow.extensions"]
knowledge-center = "deerflow_extension_knowledge_center:install"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["deerflow_extension_knowledge_center"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -410,11 +410,11 @@ and require a known model to save a model switch.
## Full-stack plugin UI
Follow [invariants](../docs/full-stack-plugins.md#frontend-implementation-invariants) for
plugin loaders/pages/actions: viewer-bound auth, native actions, i18n and
mount/dispose/abort fencing. Shadow DOM is not a sandbox. Bundled extensions need
typed en/zh `core/extensions/catalog.ts` entries with their namespace.
Catalog entries are not proof of installation or activation.
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.
### Composer references

View File

@ -16,6 +16,7 @@ import {
import {
bindFrontendServices,
openConversation,
requestExtensionRoute,
} from "@/core/extensions/services";
import { mountSurface } from "@/core/extensions/surfaces";
import { useI18n } from "@/core/i18n/hooks";
@ -48,6 +49,13 @@ function Surface({
locale,
settings: entry.settings,
threadId,
requestBackend: (path, options) =>
requestExtensionRoute(
path,
options,
abort.signal,
entry.viewer_id ?? "",
),
openConversation: (id, signal) =>
openConversation(id, (path) => router.push(path), signal),
callBackend: bindFrontendServices(

View File

@ -60,6 +60,11 @@ export const extensionCatalog = [
package: "deerflow-extension-knowledge-platform",
namespace: "knowledge-platform",
},
{
key: "knowledgeCenter",
package: "deerflow-extension-knowledge-center",
namespace: "knowledge-center",
},
{ key: "lightrag", package: "deerflow-extension-lightrag", namespace: null },
{ key: "weknora", package: "deerflow-extension-weknora", namespace: null },
{ key: "example", package: "deerflow-extension-example", namespace: null },

View File

@ -7,6 +7,10 @@ export type ExtensionSettings = Readonly<
Record<string, boolean | number | string>
>;
export type SurfaceSlot = "page";
export type ExtensionRequestOptions = Pick<
RequestInit,
"method" | "headers" | "body" | "signal"
>;
export type SurfaceContext = {
namespace: string;
locale: string;
@ -14,6 +18,11 @@ export type SurfaceContext = {
threadId?: string;
signal: AbortSignal;
callBackend: FrontendServices["callBackend"];
/** Authenticated HTTP to contributed /api/extensions/ routes. Routes own authorization. */
requestBackend?: (
path: string,
options?: ExtensionRequestOptions,
) => Promise<Response>;
/** Host resolves current thread ownership before navigating; unavailable on older hosts. */
openConversation?: (threadId: string) => Promise<void>;
};

View File

@ -9,8 +9,40 @@ import type {
ConversationActionContext,
FrontendContribution,
FrontendServices,
ExtensionRequestOptions,
} from "./contracts";
/** Same Gateway and CSRF transport as host calls; never replay an HTTP mutation. */
export async function requestExtensionRoute(
path: string,
options: ExtensionRequestOptions = {},
signal: AbortSignal,
viewerId: string,
): Promise<Response> {
signal.throwIfAborted();
if (!viewerId) throw new Error("Reload this extension to bind its viewer");
// Decode once for validation only; forward the original encoded resource IDs.
const decoded = decodeURIComponent(path.split("?")[0]!);
if (
!decoded.startsWith("/api/extensions/") ||
/[\\\\#\u0000-\u0020]/.test(path) ||
decoded.split("/").some((part) => part === "." || part === "..") ||
decoded.includes("\\") ||
decoded.includes("%")
)
throw new Error("Expected a contributed extension route");
const headers = new Headers(options.headers);
headers.set("X-Deerflow-Plugin-Viewer", viewerId);
const response = await fetch(`${getBackendBaseURL()}${path}`, {
...options,
headers,
signal: options.signal ? AbortSignal.any([signal, options.signal]) : signal,
redirect: "error",
});
signal.throwIfAborted();
return response;
}
export type HostServices = Omit<FrontendServices, "callBackend">;
/** Namespace comes from the installed page snapshot, never from action input. */

View File

@ -29,6 +29,19 @@ export function mountSurface(
controller = surface.mount(root, {
...context,
signal: abort.signal,
requestBackend: context.requestBackend
? async (path, options) => {
abort.signal.throwIfAborted();
const response = await context.requestBackend!(path, {
...options,
signal: options?.signal
? AbortSignal.any([abort.signal, options.signal])
: abort.signal,
});
abort.signal.throwIfAborted();
return response;
}
: undefined,
openConversation: context.openConversation
? async (threadId) => {
abort.signal.throwIfAborted();

View File

@ -95,6 +95,11 @@ export const enUS: Translations = {
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:

View File

@ -38,6 +38,7 @@ export interface Translations {
| "knowledge"
| "knowledgeManagement"
| "knowledgePlatform"
| "knowledgeCenter"
| "weknora"
| "lightrag"
| "example",

View File

@ -89,6 +89,11 @@ export const zhCN: Translations = {
description:
"在管理员明确授权后管理多层级空间、资源共享与继承策略。需要安装和配置,不包含管理页面,也不改变全局检索权限。",
},
knowledgeCenter: {
title: "知识中心",
description:
"浏览授权空间,导入文件、网址和文本,并查询文档处理状态。需要单独配置知识平台与知识管理扩展。",
},
knowledgeManagement: {
title: "知识入库与处理",
description:

View File

@ -0,0 +1,484 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page, type Request } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const pageURL = "/workspace/extensions/knowledge-center/library";
const api = "/api/extensions/knowledge-platform";
const assetRoot = path.resolve(
process.cwd(),
"../examples/deerflow-extension-knowledge-center/deerflow_extension_knowledge_center",
);
const resource = {
resource_id: "resource-a",
provider: "weknora",
connection_id: "local",
native_base_id: "Team handbook",
dataset_id: "wk1_fixture",
operations: [
"capabilities",
"manual",
"url",
"upload",
"status",
"reprocess",
"cancel",
],
tools: [],
processing: {},
};
const result = {
version: 1,
provider: "weknora",
connection_id: "local",
dataset_id: "wk1_fixture",
document_id: "doc-1",
state: "unknown",
provider_state: "new-provider-state",
searchable: false,
};
async function setup(
page: Page,
options: {
locale?: string;
capabilitiesStatus?: number;
writeStatus?: number;
disabled?: boolean;
empty?: boolean;
} = {},
) {
mockLangGraphAPI(page);
const calls: Request[] = [];
let operations = [...resource.operations];
await page.route("**/api/plugins**", async (route) => {
const pathname = new URL(route.request().url()).pathname;
const entry = `/api/plugins/knowledge-center/assets/${"a".repeat(64)}/static/index.mjs`;
if (pathname.endsWith("/api/plugins"))
return route.fulfill({
json: [
{
namespace: "knowledge-center",
viewer_id: "default",
title: "Knowledge center",
description: "",
module: "knowledge-center.v1",
entry,
transport: "assets-v1",
settings: { enabled: !options.disabled },
backend_actions: [],
},
],
});
const filename = pathname.split("/").pop()!;
if (
!["index.mjs", "center.mjs", "copy.mjs", "styles.css"].includes(filename)
)
return route.fulfill({ status: 404 });
return route.fulfill({
body: await readFile(path.join(assetRoot, "static", filename)),
contentType: filename.endsWith("css") ? "text/css" : "text/javascript",
});
});
await page.route(`**${api}/**`, async (route) => {
const req = route.request();
calls.push(req);
const pathname = new URL(req.url()).pathname;
if (pathname.endsWith("/spaces"))
return route.fulfill({
json: {
revision: 1,
items: options.empty
? []
: [{ space_id: "team", title: "Research team", role: "manager" }],
},
});
if (pathname.endsWith("/spaces/team"))
return route.fulfill({
json: {
revision: 1,
title: "Research team",
resources: [
resource,
{
...resource,
resource_id: "resource-b",
native_base_id: "Other handbook",
},
],
},
});
if (pathname.endsWith("/capabilities"))
return route.fulfill({
status: options.capabilitiesStatus ?? 200,
json: {
version: 1,
operations,
processing_options: [
"summary_enabled",
"chunk_size",
"chunk_overlap",
],
pipeline: {},
},
});
if (req.method() === "POST")
return route.fulfill({
status: options.writeStatus ?? 202,
json: result,
});
return route.fulfill({
json: {
...result,
state: "completed",
provider_state: "done",
searchable: true,
},
});
});
await page.context().addCookies([
{
name: "locale",
value: options.locale ?? "en-US",
url: test.info().project.use.baseURL!,
},
{
name: "csrf_token",
value: "knowledge-test-token",
url: test.info().project.use.baseURL!,
},
]);
return {
calls,
revoke: () => {
operations = ["capabilities", "status"];
},
};
}
for (const zh of [false, true]) {
test(`knowledge center import, status and explicit processing (${zh ? "zh mobile" : "en desktop"})`, async ({
page,
}) => {
if (zh) await page.setViewportSize({ width: 375, height: 812 });
const { calls } = await setup(page, { locale: zh ? "zh-CN" : "en-US" });
await page.goto(pageURL, { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", {
name: zh ? "让知识随时可用" : "Your knowledge, ready to work",
}),
).toBeVisible();
await page
.getByLabel(zh ? "标题" : "Title", { exact: true })
.fill("A source worth keeping");
await page
.getByLabel(zh ? "文档内容" : "Document text", { exact: true })
.fill("Synthetic knowledge for a browser contract test.");
await page
.getByRole("button", { name: zh ? "提交文档" : "Submit document" })
.click();
const row = page
.getByRole("article")
.filter({ has: page.getByRole("heading", { name: "doc-1" }) });
await expect(row).toContainText(
zh ? "处理状态: 未知" : "Processing state: Unknown",
);
expect(calls.filter((r) => r.method() === "POST")).toHaveLength(1);
expect(
calls.find((r) => r.method() === "POST")!.headers()["x-csrf-token"],
).toBe("knowledge-test-token");
await row
.getByRole("button", { name: zh ? "刷新状态" : "Refresh status" })
.click();
await expect(row).toContainText(
zh ? "处理状态: 已完成" : "Processing state: Completed",
);
await row
.getByRole("button", { name: zh ? "重新处理" : "Reprocess", exact: true })
.click();
expect(calls.filter((r) => r.method() === "POST")).toHaveLength(1);
await row
.getByRole("button", { name: zh ? "确认操作" : "Confirm", exact: true })
.click();
await expect
.poll(() => calls.filter((r) => r.method() === "POST").length)
.toBe(2);
await expect(
page.getByRole("button", { name: zh ? "提交文档" : "Submit document" }),
).toBeEnabled();
await row
.getByRole("button", {
name: zh ? "取消处理" : "Cancel processing",
exact: true,
})
.click();
await row
.getByRole("button", { name: zh ? "确认操作" : "Confirm", exact: true })
.click();
await expect
.poll(() => calls.filter((r) => r.method() === "POST").length)
.toBe(3);
await expect(
page.getByText(zh ? /此连接暂不支持/ : /Entry editing, version history/),
).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
if (process.env.EXTENSION_SCREENSHOT_DIR) {
await page
.getByRole("heading", {
name: zh ? "让知识随时可用" : "Your knowledge, ready to work",
})
.scrollIntoViewIfNeeded();
await page.screenshot({
path: path.join(
process.env.EXTENSION_SCREENSHOT_DIR,
`knowledge-${zh ? "zh-mobile" : "en-desktop"}.png`,
),
fullPage: true,
});
}
});
}
test("URL and raw file submission preserve processing fields", async ({
page,
}) => {
const { calls } = await setup(page);
await page.goto(pageURL, { waitUntil: "domcontentloaded" });
await page.getByLabel("Import document", { exact: true }).selectOption("url");
await page.getByLabel("Document URL").fill("https://example.com/document");
await page.getByText("Processing options", { exact: true }).click();
await page.getByLabel("Summary", { exact: true }).selectOption("false");
await page.getByRole("button", { name: "Submit document" }).click();
await expect
.poll(() => calls.filter((r) => r.method() === "POST").length)
.toBe(1);
expect(
calls.find((r) => r.method() === "POST")!.postDataJSON(),
).toMatchObject({ kind: "url", processing: { summary_enabled: false } });
await expect(
page.getByRole("button", { name: "Submit document" }),
).toBeEnabled();
await page
.getByLabel("Import document", { exact: true })
.selectOption("upload");
await page.getByLabel("File (up to 16 MiB)").setInputFiles({
name: "notes.txt",
mimeType: "text/plain",
buffer: Buffer.from("synthetic upload"),
});
await page.getByRole("button", { name: "Submit document" }).click();
await expect
.poll(() => calls.filter((r) => r.method() === "POST").length)
.toBe(2);
const upload = calls.filter((r) => r.method() === "POST")[1]!;
expect(upload.url()).toContain("/files?filename=notes.txt");
expect(upload.postData()).toBe("synthetic upload");
expect(upload.headers()["x-processing-options"]).toBe(
'{"summary_enabled":false}',
);
});
test("uncertain writes require reconciliation and never replay", async ({
page,
}) => {
const { calls } = await setup(page, { writeStatus: 504 });
await page.goto(pageURL, { waitUntil: "domcontentloaded" });
await page.getByLabel("Title", { exact: true }).fill("Uncertain write");
await page.getByLabel("Document text").fill("Synthetic text");
await page.getByRole("button", { name: "Submit document" }).click();
await expect(page.getByText(/The provider may have accepted/)).toBeVisible();
await expect(
page.getByRole("button", { name: "Submit document" }),
).toBeDisabled();
expect(calls.filter((r) => r.method() === "POST")).toHaveLength(1);
await page
.getByRole("button", { name: "I checked the upstream result" })
.click();
await expect(
page.getByRole("button", { name: "Submit document" }),
).toBeEnabled();
expect(calls.filter((r) => r.method() === "POST")).toHaveLength(1);
});
test("invalid URL, chunk pair and oversized upload never submit", async ({
page,
}) => {
const { calls } = await setup(page);
await page.goto(pageURL, { waitUntil: "domcontentloaded" });
const mode = page.getByLabel("Import document", { exact: true });
const submit = page.getByRole("button", { name: "Submit document" });
await mode.selectOption("url");
await page
.getByLabel("Document URL")
.fill("https://user:password@example.com/document");
await submit.click();
await expect(
page.getByRole("alert").filter({ hasText: /without credentials/ }),
).toBeVisible();
await page.getByLabel("Document URL").fill("https://example.com/document");
await page.getByText("Processing options", { exact: true }).click();
await page.getByLabel("Chunk size (64–8192)").fill("256");
await submit.click();
await expect(
page.getByRole("alert").filter({ hasText: /Supply both chunk fields/ }),
).toBeVisible();
await page.getByLabel("Chunk size (64–8192)").fill("");
await mode.selectOption("upload");
await page.getByLabel("File (up to 16 MiB)").setInputFiles({
name: "large.txt",
mimeType: "text/plain",
buffer: Buffer.alloc(16 * 1024 * 1024 + 1),
});
await submit.click();
await expect(
page.getByRole("alert").filter({ hasText: /no larger than 16 MiB/ }),
).toBeVisible();
expect(calls.filter((r) => r.method() === "POST")).toHaveLength(0);
});
test("revoked capabilities stop submission before any write", async ({
page,
}) => {
const fixture = await setup(page);
await page.goto(pageURL, { waitUntil: "domcontentloaded" });
await page.getByLabel("Title", { exact: true }).fill("Revoked write");
await page.getByLabel("Document text").fill("Synthetic text");
fixture.revoke();
await page.getByRole("button", { name: "Submit document" }).click();
await expect(
page.getByRole("alert").filter({ hasText: /does not support/ }),
).toBeVisible();
expect(fixture.calls.filter((r) => r.method() === "POST")).toHaveLength(0);
});
test("browse-only members see no management controls", async ({ page }) => {
await setup(page, { capabilitiesStatus: 403 });
await page.goto(pageURL, { waitUntil: "domcontentloaded" });
await expect(page.getByText(/You can browse this space/)).toBeVisible();
await expect(
page.getByRole("button", { name: "Submit document" }),
).toHaveCount(0);
await expect(
page.getByLabel("Knowledge base", { exact: true }),
).toBeEnabled();
});
test("a late status read cannot overwrite a confirmed cancellation", async ({
page,
}) => {
await setup(page);
await page.goto(pageURL, { waitUntil: "domcontentloaded" });
await page.getByLabel("Document ID", { exact: true }).fill("doc-1");
await page.getByRole("button", { name: "Check status", exact: true }).click();
const row = page
.getByRole("article")
.filter({ has: page.getByRole("heading", { name: "doc-1" }) });
await expect(row).toContainText("Completed");
let release!: () => void;
const delayed = new Promise<void>((resolve) => {
release = resolve;
});
let readStarted = false;
await page.route(`**${api}/**/documents/doc-1`, async (route) => {
readStarted = true;
await delayed;
await route.fulfill({ json: { ...result, state: "running" } });
});
await page.route(`**${api}/**/documents/doc-1/cancel`, (route) =>
route.fulfill({ status: 202, json: { ...result, state: "cancelled" } }),
);
await row.getByRole("button", { name: "Refresh status" }).click();
await expect.poll(() => readStarted).toBe(true);
await row
.getByRole("button", { name: "Cancel processing", exact: true })
.click();
await row.getByRole("button", { name: "Confirm", exact: true }).click();
await expect(row).toContainText("Cancelled");
const response = page.waitForResponse((r) =>
r.url().endsWith("/documents/doc-1"),
);
release();
await (await response).finished();
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
}),
),
);
await expect(row).toContainText("Cancelled");
});
test("dark theme follows host tokens and unavailable platform has a recovery control", async ({
page,
}) => {
await page.addInitScript(() => localStorage.setItem("theme", "dark"));
await setup(page);
await page.goto(pageURL, { waitUntil: "domcontentloaded" });
await expect(page.getByLabel("Title", { exact: true })).toBeVisible();
if (process.env.EXTENSION_SCREENSHOT_DIR)
await page.screenshot({
path: path.join(
process.env.EXTENSION_SCREENSHOT_DIR,
"knowledge-dark.png",
),
fullPage: true,
});
await page.route(`**${api}/spaces`, (route) =>
route.fulfill({ status: 503, json: {} }),
);
await page.getByRole("button", { name: "Reload spaces" }).click();
await expect(
page
.getByRole("alert")
.filter({ hasText: /request could not be completed/ }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Reload spaces" }),
).toBeEnabled();
});
test("resource changes clear snapshots and do not accumulate listeners", async ({
page,
}) => {
const { calls } = await setup(page);
await page.goto(pageURL, { waitUntil: "domcontentloaded" });
await page.getByLabel("Document ID", { exact: true }).fill("doc-1");
await page.getByRole("button", { name: "Check status", exact: true }).click();
await expect(page.getByRole("heading", { name: "doc-1" })).toBeVisible();
const selector = page.getByLabel("Knowledge base", { exact: true });
await selector.selectOption("resource-b");
await expect(page.getByRole("heading", { name: "doc-1" })).toHaveCount(0);
await expect(page.getByLabel("Document ID", { exact: true })).toBeVisible();
const before = calls.filter((r) => r.url().endsWith("/capabilities")).length;
await page.getByRole("button", { name: "Reload spaces" }).click();
await expect(page.getByLabel("Document ID", { exact: true })).toBeVisible();
await selector.selectOption("resource-b");
await expect(page.getByLabel("Document ID", { exact: true })).toBeVisible();
expect(
calls.filter((r) => r.url().endsWith("/capabilities")).length - before,
).toBe(2);
});
test("disabled page and empty membership have explicit states", async ({
page,
}) => {
await setup(page, { empty: true });
await page.goto(pageURL, { waitUntil: "domcontentloaded" });
await expect(page.getByText(/No accessible spaces/)).toBeVisible();
await setup(page, { disabled: true });
await page.reload();
await expect(
page.getByRole("heading", { name: "Extension page unavailable" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Knowledge center", exact: true }),
).toHaveCount(0);
});

View File

@ -6,6 +6,7 @@ import {
conversationText,
latestVisibleAnswer,
openConversation,
requestExtensionRoute,
} from "@/core/extensions/services";
import type { AgentThread } from "@/core/threads/types";
@ -24,6 +25,67 @@ beforeEach(() => {
rs.clearAllMocks();
});
test("extension HTTP transport uses host auth and abort without replaying writes", async () => {
const signal = new AbortController().signal;
const response = { ok: false, status: 409 };
request.mockResolvedValue(response);
expect(
await requestExtensionRoute(
"/api/extensions/example/files?filename=a.txt",
{
method: "POST",
body: "data",
headers: { "x-deerflow-plugin-viewer": "forged" },
},
signal,
"alice",
),
).toBe(response);
expect(request).toHaveBeenCalledWith(
"/api/extensions/example/files?filename=a.txt",
expect.objectContaining({
method: "POST",
body: "data",
signal,
redirect: "error",
}),
);
expect(request).toHaveBeenCalledTimes(1);
expect(
request.mock.calls[0]![1].headers.get("X-Deerflow-Plugin-Viewer"),
).toBe("alice");
});
test("extension transport rejects foreign URLs, traversal and late calls", async () => {
const signal = new AbortController().signal;
for (const path of [
"https://evil.test/api/extensions/a",
"//evil.test/api/extensions/a",
"/api/plugins",
"/api/extensions/../auth/me",
"/api/extensions/a/%2e%2e/%2e%2e/auth/me",
"/api/extensions/a\\b",
"/api/extensions/a#fragment",
]) {
await expect(
requestExtensionRoute(path, {}, signal, "alice"),
).rejects.toThrow();
}
const aborted = AbortSignal.abort();
await expect(
requestExtensionRoute(
"/api/extensions/example/spaces",
{},
aborted,
"alice",
),
).rejects.toThrow();
expect(request).not.toHaveBeenCalled();
await expect(
requestExtensionRoute("/api/extensions/example/spaces", {}, signal, ""),
).rejects.toThrow();
});
test("bookmark receives only the last visible assistant answer", async () => {
const thread = {
thread_id: "t",