mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-24 01:37:50 +08:00
Plugin config forms can now ask the plugin while they are filled in:
- x-options {name, dependsOn, search} loads a field's choices from
/v1/options/{name}. POST /api/v1/plugins/:id/options merges the form's
values with the stored secrets (system, tenant or instance scope) so a
redacted token still works. Go and Python SDKs, OpenAPI and conformance
cover the endpoint.
- x-oauth turns a string field into an account connection. WeKnora runs
the authorization code flow (PKCE, client credentials from the plugin's
system config), keeps the tokens sealed in plugin_oauth_connections and
the field holds "oauth:<id>". The invoker swaps references for fresh
access tokens at call time, refreshing near expiry. Callback:
/api/v1/plugin-oauth/callback, one-time states in Redis or memory.
- SchemaForm gains an options widget (dependsOn reload, remote search)
and an OAuth connect widget; connector, web search, workspace and
platform config forms provide the plugin behind them.
Host plugins are capped per runtime.resources on Linux: RLIMIT_DATA for
memory (WEKNORA_PLUGIN_MEMORY_DEFAULT when undeclared) and, with a
delegated cgroup v2 (WEKNORA_PLUGIN_CGROUP), memory.max and cpu.max.
177 lines
5.5 KiB
Markdown
177 lines
5.5 KiB
Markdown
# weknora-plugin
|
|
|
|
Build [WeKnora](https://github.com/Tencent/WeKnora) code plugins in Python.
|
|
The package speaks the same extension protocol as the Go SDK. It uses only
|
|
the standard library and supports Python 3.9 and later.
|
|
|
|
```python
|
|
from weknora_plugin import Plugin, SearchResult
|
|
|
|
plugin = Plugin("acme.search", "1.0.0") # must match plugin.yaml
|
|
|
|
|
|
@plugin.web_search("web")
|
|
def search(call, q):
|
|
# call.tenant_id, call.locale, call.system / call.tenant / call.instance
|
|
return [SearchResult(title=q.query, url="https://example.com")]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
plugin.serve()
|
|
```
|
|
|
|
## Contributions
|
|
|
|
**Web search.**
|
|
|
|
```python
|
|
@plugin.web_search(id)
|
|
def search(call, q: SearchInput) -> list[SearchResult]: ...
|
|
```
|
|
|
|
**Parser.** Return a `ParseOutput`, or just the Markdown as a `str`.
|
|
|
|
```python
|
|
@plugin.parser(id)
|
|
def parse(call, doc: ParseInput) -> ParseOutput: ...
|
|
```
|
|
|
|
- `doc.content` is the file as bytes.
|
|
- Images the Markdown references as `` go in `images` with a
|
|
matching `original_ref`.
|
|
|
|
**Connector.** A class, registered with `@plugin.connector(id)` (or pass
|
|
an instance):
|
|
|
|
```python
|
|
@plugin.connector("notes")
|
|
class Notes:
|
|
def validate(self, call, cfg: ConnectorConfig) -> None: ...
|
|
def list_resources(self, call, cfg, parent_id: str) -> list[Resource]: ...
|
|
def fetch(self, call, cfg, inp: FetchInput, stream: Stream) -> Cursor | None:
|
|
for note in changed_since(inp.cursor):
|
|
stream.item(FetchedItem(external_id=note.id, title=note.title, content=note.markdown))
|
|
stream.checkpoint(Cursor(state={"after": last_id})) # at page boundaries
|
|
return Cursor(state={"after": last_id})
|
|
# optional: resolve_ancestors(self, call, cfg, resource_ids) -> list[str]
|
|
```
|
|
|
|
**Configuration check.**
|
|
|
|
```python
|
|
@plugin.config_validator
|
|
def validate(call) -> None: ...
|
|
```
|
|
|
|
**Pages.** Requests from the plugin's pages (`pages`,
|
|
`settingsSections`, `kbTabs` in plugin.yaml) arrive at one handler. Return a
|
|
`UIResponse`, or any JSON value for a 200:
|
|
|
|
```python
|
|
@plugin.ui
|
|
def ui(call, req: UIRequest):
|
|
# req.mount ("pages/links"), req.method, req.path, req.body, req.role
|
|
return UIResponse(status=200, body=links)
|
|
```
|
|
|
|
The pages themselves are HTML under `ui/` that use
|
|
[`@weknora/plugin-ui`](../../packages/plugin-ui).
|
|
|
|
**Events and webhooks.**
|
|
|
|
```python
|
|
@plugin.on_event
|
|
def on_event(call, ev: EventDelivery): # permissions.events; dedupe on ev.id
|
|
...
|
|
|
|
@plugin.webhook("notes") # contributes.webhooks
|
|
def notes(call, req: WebhookRequest): # req.headers, req.body, req.json()
|
|
return WebhookResponse(status=204)
|
|
```
|
|
|
|
**Dynamic choices.** A field with `x-options: {name: projects}` in a
|
|
config schema gets its choices from the plugin. Return `Option`s or
|
|
`(value, label)` pairs:
|
|
|
|
```python
|
|
@plugin.options("projects")
|
|
def projects(call, inp: OptionsInput):
|
|
# inp.scope, inp.field, inp.query; the form's values are in call.tenant / call.instance
|
|
if not call.tenant.get("token"):
|
|
raise invalid_config("enter the token first", {"token": "required"})
|
|
return [("p1", "Project one"), ("p2", "Project two")]
|
|
```
|
|
|
|
**OAuth.** An `x-oauth` field needs no code: WeKnora runs the flow, and
|
|
the plugin finds a fresh access token where the field is (see the
|
|
[Go SDK README](../README.md#dynamic-choices-and-oauth)).
|
|
|
|
## Errors
|
|
|
|
Raise `PluginError(ErrorCode.X, "message")` to choose what WeKnora sees:
|
|
|
|
- `unauthorized`: credentials stopped working.
|
|
- `rate_limited` (with `retry_after=`): back off.
|
|
- `unavailable`: retry later.
|
|
- `invalid_config(message, {"settings.url": "required"})`: point at form
|
|
fields.
|
|
|
|
Any other exception becomes `internal`, and its traceback goes to stderr.
|
|
|
|
## Calling back into WeKnora
|
|
|
|
A plugin granted Host API scopes (`permissions.hostApi` in plugin.yaml) gets
|
|
a client per call from `call.host()`. It returns `None` without a grant.
|
|
|
|
```python
|
|
host = call.host()
|
|
host.kv_put("cursor", {"page": 3}, ttl=3600)
|
|
host.kv_get("cursor", default={})
|
|
```
|
|
|
|
The token behind it lasts a few minutes: use it within the call.
|
|
|
|
## Running
|
|
|
|
`plugin.serve()` picks its mode from the environment.
|
|
|
|
- **Host mode.** The WeKnora plugin host starts the plugin with
|
|
`WEKNORA_PLUGIN_SOCKET` and `WEKNORA_PLUGIN_TOKEN`. The plugin listens
|
|
on that socket and prints the handshake on stdout. Keep stdout free of
|
|
anything else, and log to stderr (`plugin.logger`).
|
|
- **Remote mode.** Run on its own, it listens on `WEKNORA_PLUGIN_ADDR`
|
|
(default `:8080`). It accepts only requests signed with
|
|
`WEKNORA_PLUGIN_SECRET`, the secret shown when the plugin was registered.
|
|
|
|
On SIGTERM it stops accepting calls and lets the ones in flight finish.
|
|
Each thread handles one call. `call.deadline` says when WeKnora stops
|
|
waiting.
|
|
|
|
## Packaging for the plugin host
|
|
|
|
```yaml
|
|
runtime: { type: host, kind: python, entry: main.py }
|
|
```
|
|
|
|
WeKnora runs the entry with its own `python3`. It puts the package's
|
|
`vendor/` directory and the package root on `PYTHONPATH`. Vendor the SDK
|
|
and any dependencies:
|
|
|
|
```bash
|
|
pip install --target vendor weknora-plugin # or copy src/weknora_plugin
|
|
```
|
|
|
|
Pure-Python dependencies work everywhere. Ones with native code must match
|
|
the server's platform. For a complete plugin, see
|
|
`examples/plugins/notebooks` (a Jupyter notebook parser).
|
|
|
|
## Testing
|
|
|
|
`plugin.test_server()` serves the plugin on a loopback port without
|
|
authentication, for unit tests. To check the protocol end to end, run the
|
|
conformance suite from the Go SDK against a running plugin:
|
|
|
|
```bash
|
|
go run github.com/Tencent/WeKnora/pluginsdk/cmd/weknora-plugin-conformance -url http://localhost:8080 -secret $SECRET
|
|
```
|