mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-25 09:42:26 +08:00
Events:
- A plugin lists what it wants in permissions.events: knowledge.ingested,
knowledge.failed, knowledge.deleted or chat.answered. The admin reviews
the list at install; a plugin that only listens needs no other
contribution.
- A publish enqueues one plugin:event task per subscribed plugin the
workspace has on (maintenance queue, Redis or Lite).
- The task calls the plugin's new /v1/events at least once. Retryable
failures retry up to 10 times, keeping the event ID; other failures drop
the event.
- The plugin host refuses a process that subscribes without serving
events.
Where events come from:
- A document reaches completed or failed through a dozen paths but a few
repository writes, so a knowledge repository wrapper raises
knowledge.ingested and knowledge.failed on the transition only:
UpdateKnowledge, UpdateKnowledgeForTransfer, UpdateKnowledgeColumns,
FinalizeSubtask, CompleteProcessingWithoutSubtasks.
- knowledge.deleted comes from the shared delete path.
- chat.answered comes from where RAG and agent answers are persisted. The
stop button's early completion, which has no question, is skipped.
Webhooks:
- contributes.webhooks gives each workspace a secret URL per webhook:
/api/v1/plugin-callbacks/{plugin}/{webhook}/{tenant}.{mac}. The MAC is
keyed from SYSTEM_AES_KEY/JWT_SECRET.
- WeKnora answers 404 unless the URL is genuine, the webhook exists and the
workspace has the plugin on. Calls are rate-limited per URL, bodies are
capped at 1 MB and cookies are stripped.
- The call is relayed to the plugin's /v1/webhooks/{id} with that
workspace's envelope; the plugin verifies the sender.
- Admins get the URLs from GET /plugins/:id/webhooks, shown with copy
buttons in the plugin center's configure drawer. Plugins get them in
context.webhooks when APP_EXTERNAL_URL is set.
SDKs:
- Go: OnEvent and Webhook.
- Python: @plugin.on_event and @plugin.webhook.
- Conformance, OpenAPI and READMEs are updated.
Also: examples/plugins/activity (Python) keeps a workspace activity feed
from events and a secret-checked note webhook, and shows it on a toolbox
page.
160 lines
4.8 KiB
Markdown
160 lines
4.8 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)
|
|
```
|
|
|
|
## 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
|
|
```
|